@jxtools/promptline 1.0.0
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/README.md +45 -0
- package/bin/promptline.mjs +181 -0
- package/index.html +15 -0
- package/package.json +55 -0
- package/promptline-prompt-queue.sh +173 -0
- package/promptline-session-end.sh +58 -0
- package/promptline-session-register.sh +113 -0
- package/src/App.tsx +70 -0
- package/src/api/client.ts +55 -0
- package/src/backend/queue-store.ts +172 -0
- package/src/components/AddPromptForm.tsx +131 -0
- package/src/components/ProjectDetail.tsx +136 -0
- package/src/components/PromptCard.tsx +279 -0
- package/src/components/SessionSection.tsx +164 -0
- package/src/components/Sidebar.tsx +127 -0
- package/src/components/StatusBar.tsx +71 -0
- package/src/hooks/useQueue.ts +6 -0
- package/src/hooks/useQueues.ts +53 -0
- package/src/hooks/useSSE.ts +37 -0
- package/src/index.css +34 -0
- package/src/main.tsx +10 -0
- package/src/types/queue.ts +33 -0
- package/tsconfig.app.json +29 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite-plugin-api.ts +307 -0
- package/vite.config.ts +14 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import type { Prompt } from '../types/queue';
|
|
3
|
+
import { api } from '../api/client';
|
|
4
|
+
|
|
5
|
+
interface PromptCardProps {
|
|
6
|
+
prompt: Prompt;
|
|
7
|
+
project: string;
|
|
8
|
+
sessionId: string;
|
|
9
|
+
onMutate: () => void;
|
|
10
|
+
onDragStart?: (id: string) => void;
|
|
11
|
+
onDragOver?: (id: string, position: 'before' | 'after') => void;
|
|
12
|
+
onDragEnd?: () => void;
|
|
13
|
+
onDrop?: (targetId: string) => void;
|
|
14
|
+
dropPosition?: 'before' | 'after' | null;
|
|
15
|
+
isDragging?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const STATUS_STYLES: Record<Prompt['status'], { color: string; badge: string; label: string }> = {
|
|
19
|
+
running: {
|
|
20
|
+
color: 'var(--color-running)',
|
|
21
|
+
badge: 'bg-[var(--color-running)]/15 text-[var(--color-running)]',
|
|
22
|
+
label: 'running',
|
|
23
|
+
},
|
|
24
|
+
pending: {
|
|
25
|
+
color: 'var(--color-pending)',
|
|
26
|
+
badge: 'bg-[var(--color-pending)]/15 text-[var(--color-pending)]',
|
|
27
|
+
label: 'pending',
|
|
28
|
+
},
|
|
29
|
+
completed: {
|
|
30
|
+
color: 'var(--color-completed)',
|
|
31
|
+
badge: 'bg-[var(--color-completed)]/15 text-[var(--color-completed)]',
|
|
32
|
+
label: 'completed',
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function PromptCard({
|
|
37
|
+
prompt,
|
|
38
|
+
project,
|
|
39
|
+
sessionId,
|
|
40
|
+
onMutate,
|
|
41
|
+
onDragStart,
|
|
42
|
+
onDragOver,
|
|
43
|
+
onDragEnd,
|
|
44
|
+
onDrop,
|
|
45
|
+
dropPosition = null,
|
|
46
|
+
isDragging = false,
|
|
47
|
+
}: PromptCardProps) {
|
|
48
|
+
const [editing, setEditing] = useState(false);
|
|
49
|
+
const [editText, setEditText] = useState(prompt.text);
|
|
50
|
+
const [saving, setSaving] = useState(false);
|
|
51
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
52
|
+
|
|
53
|
+
const styles = STATUS_STYLES[prompt.status];
|
|
54
|
+
const isCompleted = prompt.status === 'completed';
|
|
55
|
+
const isPending = prompt.status === 'pending';
|
|
56
|
+
const isRunning = prompt.status === 'running';
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (editing && textareaRef.current) {
|
|
60
|
+
textareaRef.current.focus();
|
|
61
|
+
textareaRef.current.select();
|
|
62
|
+
}
|
|
63
|
+
}, [editing]);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const el = textareaRef.current;
|
|
67
|
+
if (!el) return;
|
|
68
|
+
el.style.height = 'auto';
|
|
69
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
70
|
+
}, [editText]);
|
|
71
|
+
|
|
72
|
+
function handleEditStart() {
|
|
73
|
+
if (!isPending || editing) return;
|
|
74
|
+
setEditText(prompt.text);
|
|
75
|
+
setEditing(true);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function handleEditCancel() {
|
|
79
|
+
setEditText(prompt.text);
|
|
80
|
+
setEditing(false);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function handleEditSave() {
|
|
84
|
+
const trimmed = editText.trim();
|
|
85
|
+
if (!trimmed || saving) return;
|
|
86
|
+
setSaving(true);
|
|
87
|
+
try {
|
|
88
|
+
await api.updatePrompt(project, sessionId, prompt.id, { text: trimmed });
|
|
89
|
+
setEditing(false);
|
|
90
|
+
onMutate();
|
|
91
|
+
} catch {
|
|
92
|
+
// Keep edit open on error
|
|
93
|
+
} finally {
|
|
94
|
+
setSaving(false);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function handleEditKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
|
99
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
handleEditSave();
|
|
102
|
+
}
|
|
103
|
+
if (e.key === 'Escape') {
|
|
104
|
+
handleEditCancel();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function handleDelete(e: React.MouseEvent) {
|
|
109
|
+
e.stopPropagation();
|
|
110
|
+
const confirmed = window.confirm('Delete this prompt?');
|
|
111
|
+
if (!confirmed) return;
|
|
112
|
+
try {
|
|
113
|
+
await api.deletePrompt(project, sessionId, prompt.id);
|
|
114
|
+
onMutate();
|
|
115
|
+
} catch {
|
|
116
|
+
// Silent fail
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function handleCardClick() {
|
|
121
|
+
if (!editing && isPending) {
|
|
122
|
+
handleEditStart();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div
|
|
128
|
+
className="relative group transition-all duration-150"
|
|
129
|
+
draggable={isPending && !editing}
|
|
130
|
+
onDragStart={isPending && !editing ? (e) => {
|
|
131
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
132
|
+
e.dataTransfer.setData('text/plain', prompt.id);
|
|
133
|
+
onDragStart?.(prompt.id);
|
|
134
|
+
} : undefined}
|
|
135
|
+
onDragOver={isPending ? (e) => {
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
e.dataTransfer.dropEffect = 'move';
|
|
138
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
139
|
+
const midY = rect.top + rect.height / 2;
|
|
140
|
+
onDragOver?.(prompt.id, e.clientY < midY ? 'before' : 'after');
|
|
141
|
+
} : undefined}
|
|
142
|
+
onDrop={isPending ? (e) => {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
onDrop?.(prompt.id);
|
|
145
|
+
} : undefined}
|
|
146
|
+
onDragEnd={onDragEnd}
|
|
147
|
+
aria-label={`Prompt: ${prompt.text.slice(0, 50)}`}
|
|
148
|
+
>
|
|
149
|
+
{/* Drop indicator — top */}
|
|
150
|
+
{dropPosition === 'before' && (
|
|
151
|
+
<div
|
|
152
|
+
className="absolute top-0 left-2 right-2 h-0.5 rounded-full bg-[var(--color-active)] shadow-[0_0_6px_var(--color-active)]"
|
|
153
|
+
aria-hidden="true"
|
|
154
|
+
/>
|
|
155
|
+
)}
|
|
156
|
+
{/* Drop indicator — bottom */}
|
|
157
|
+
{dropPosition === 'after' && (
|
|
158
|
+
<div
|
|
159
|
+
className="absolute bottom-0 left-2 right-2 h-0.5 rounded-full bg-[var(--color-active)] shadow-[0_0_6px_var(--color-active)]"
|
|
160
|
+
aria-hidden="true"
|
|
161
|
+
/>
|
|
162
|
+
)}
|
|
163
|
+
|
|
164
|
+
<div
|
|
165
|
+
onClick={handleCardClick}
|
|
166
|
+
className={[
|
|
167
|
+
'flex gap-0 bg-white/5 backdrop-blur-sm border border-white/10 rounded-lg overflow-hidden',
|
|
168
|
+
'transition-all duration-150',
|
|
169
|
+
isCompleted ? 'opacity-60' : '',
|
|
170
|
+
isPending && !editing ? 'cursor-pointer hover:border-white/20 hover:bg-white/8' : '',
|
|
171
|
+
isRunning ? 'border-l-0' : '',
|
|
172
|
+
isDragging ? 'opacity-40 scale-[0.98]' : '',
|
|
173
|
+
].join(' ')}
|
|
174
|
+
>
|
|
175
|
+
{/* Left color border */}
|
|
176
|
+
<div
|
|
177
|
+
className={['w-1 shrink-0', isRunning ? 'animate-pulse-dot' : ''].join(' ')}
|
|
178
|
+
style={{ background: styles.color }}
|
|
179
|
+
aria-hidden="true"
|
|
180
|
+
/>
|
|
181
|
+
|
|
182
|
+
{/* Drag handle — only for pending */}
|
|
183
|
+
{isPending && !editing && (
|
|
184
|
+
<div className="flex items-center px-2 shrink-0 cursor-grab active:cursor-grabbing">
|
|
185
|
+
<span
|
|
186
|
+
className="text-[var(--color-muted)]/50 text-sm select-none"
|
|
187
|
+
aria-hidden="true"
|
|
188
|
+
title="Drag to reorder"
|
|
189
|
+
>
|
|
190
|
+
⠿
|
|
191
|
+
</span>
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{/* Card body */}
|
|
196
|
+
<div className="flex-1 px-3 py-3 min-w-0">
|
|
197
|
+
{/* Top row: status badge + actions */}
|
|
198
|
+
<div className="flex items-start justify-between gap-2 mb-2">
|
|
199
|
+
<span
|
|
200
|
+
className={[
|
|
201
|
+
'inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium leading-none',
|
|
202
|
+
styles.badge,
|
|
203
|
+
].join(' ')}
|
|
204
|
+
aria-label={`Status: ${styles.label}`}
|
|
205
|
+
>
|
|
206
|
+
{styles.label}
|
|
207
|
+
</span>
|
|
208
|
+
|
|
209
|
+
{/* Action buttons */}
|
|
210
|
+
{!editing && !isRunning && (
|
|
211
|
+
<button
|
|
212
|
+
type="button"
|
|
213
|
+
onClick={handleDelete}
|
|
214
|
+
className={[
|
|
215
|
+
'text-xs px-1.5 py-0.5 rounded text-[var(--color-muted)] cursor-pointer opacity-0 group-hover:opacity-100',
|
|
216
|
+
'hover:text-red-400 hover:bg-red-400/10',
|
|
217
|
+
'transition-all duration-100 focus:outline-none',
|
|
218
|
+
].join(' ')}
|
|
219
|
+
aria-label="Delete prompt"
|
|
220
|
+
>
|
|
221
|
+
✕
|
|
222
|
+
</button>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
{/* Prompt text or edit textarea */}
|
|
227
|
+
{editing ? (
|
|
228
|
+
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
|
|
229
|
+
<textarea
|
|
230
|
+
ref={textareaRef}
|
|
231
|
+
value={editText}
|
|
232
|
+
onChange={(e) => setEditText(e.target.value)}
|
|
233
|
+
onKeyDown={handleEditKeyDown}
|
|
234
|
+
className={[
|
|
235
|
+
'w-full bg-transparent text-sm text-[var(--color-text)] leading-relaxed resize-none',
|
|
236
|
+
'outline-none border-b border-[var(--color-active)]/30 pb-1',
|
|
237
|
+
'placeholder:text-[var(--color-muted)]/60',
|
|
238
|
+
].join(' ')}
|
|
239
|
+
aria-label="Edit prompt text"
|
|
240
|
+
disabled={saving}
|
|
241
|
+
/>
|
|
242
|
+
<div className="flex items-center justify-end gap-2">
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
onClick={handleEditCancel}
|
|
246
|
+
disabled={saving}
|
|
247
|
+
className={[
|
|
248
|
+
'text-xs px-2.5 py-1 rounded border border-[var(--color-border)] text-[var(--color-muted)] cursor-pointer',
|
|
249
|
+
'hover:text-[var(--color-text)] hover:border-white/20 transition-all duration-150',
|
|
250
|
+
'focus:outline-none disabled:opacity-40 disabled:cursor-not-allowed',
|
|
251
|
+
].join(' ')}
|
|
252
|
+
>
|
|
253
|
+
Cancel
|
|
254
|
+
</button>
|
|
255
|
+
<button
|
|
256
|
+
type="button"
|
|
257
|
+
onClick={handleEditSave}
|
|
258
|
+
disabled={!editText.trim() || saving}
|
|
259
|
+
className={[
|
|
260
|
+
'text-xs px-2.5 py-1 rounded font-medium transition-all duration-150 cursor-pointer',
|
|
261
|
+
'bg-[var(--color-active)]/15 text-[var(--color-active)] border border-[var(--color-active)]/30',
|
|
262
|
+
'hover:bg-[var(--color-active)]/25 focus:outline-none',
|
|
263
|
+
'disabled:opacity-40 disabled:cursor-not-allowed',
|
|
264
|
+
].join(' ')}
|
|
265
|
+
>
|
|
266
|
+
{saving ? 'Saving...' : 'Save'}
|
|
267
|
+
</button>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
) : (
|
|
271
|
+
<p className="text-sm text-[var(--color-text)] leading-relaxed whitespace-pre-wrap break-words">
|
|
272
|
+
{prompt.text}
|
|
273
|
+
</p>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { useState, useRef } from 'react';
|
|
2
|
+
import { api } from '../api/client';
|
|
3
|
+
import type { SessionWithStatus, SessionStatus, Prompt } from '../types/queue';
|
|
4
|
+
import { PromptCard } from './PromptCard';
|
|
5
|
+
import { AddPromptForm } from './AddPromptForm';
|
|
6
|
+
|
|
7
|
+
interface SessionSectionProps {
|
|
8
|
+
session: SessionWithStatus;
|
|
9
|
+
project: string;
|
|
10
|
+
onMutate: () => void;
|
|
11
|
+
defaultExpanded?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function StatusDot({ status }: { status: SessionStatus }) {
|
|
15
|
+
if (status === 'active') {
|
|
16
|
+
return (
|
|
17
|
+
<span
|
|
18
|
+
className="animate-pulse-dot inline-block w-2 h-2 rounded-full bg-[var(--color-active)] shrink-0"
|
|
19
|
+
aria-label="Active session"
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return (
|
|
24
|
+
<span
|
|
25
|
+
className="inline-block w-2 h-2 rounded-full bg-[var(--color-idle)] shrink-0"
|
|
26
|
+
aria-label="Idle session"
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function SessionSection({ session, project, onMutate, defaultExpanded = true }: SessionSectionProps) {
|
|
32
|
+
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
33
|
+
const [dragOver, setDragOver] = useState<{ id: string; position: 'before' | 'after' } | null>(null);
|
|
34
|
+
const [draggingId, setDraggingId] = useState<string | null>(null);
|
|
35
|
+
const dragSourceRef = useRef<string | null>(null);
|
|
36
|
+
|
|
37
|
+
const activePrompts = session.prompts.filter(p => p.status !== 'completed');
|
|
38
|
+
const completedPrompts = session.prompts.filter(p => p.status === 'completed').reverse();
|
|
39
|
+
const pendingCount = session.prompts.filter(p => p.status === 'pending').length;
|
|
40
|
+
|
|
41
|
+
const displayName = session.sessionName || '(session)';
|
|
42
|
+
|
|
43
|
+
function handleDragStart(id: string) {
|
|
44
|
+
dragSourceRef.current = id;
|
|
45
|
+
setDraggingId(id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function handleDragOver(id: string, position: 'before' | 'after') {
|
|
49
|
+
if (!dragSourceRef.current || dragSourceRef.current === id) return;
|
|
50
|
+
setDragOver(prev => {
|
|
51
|
+
if (prev?.id === id && prev?.position === position) return prev;
|
|
52
|
+
return { id, position };
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function handleDragEnd() {
|
|
57
|
+
setDragOver(null);
|
|
58
|
+
setDraggingId(null);
|
|
59
|
+
dragSourceRef.current = null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function handleDrop(targetId: string) {
|
|
63
|
+
const sourceId = dragSourceRef.current;
|
|
64
|
+
const position = dragOver?.position ?? 'before';
|
|
65
|
+
if (!sourceId || sourceId === targetId) {
|
|
66
|
+
handleDragEnd();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const pendingPrompts = session.prompts.filter(p => p.status === 'pending');
|
|
71
|
+
const sourceIndex = pendingPrompts.findIndex(p => p.id === sourceId);
|
|
72
|
+
const targetIndex = pendingPrompts.findIndex(p => p.id === targetId);
|
|
73
|
+
if (sourceIndex === -1 || targetIndex === -1) {
|
|
74
|
+
handleDragEnd();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const reordered = [...pendingPrompts];
|
|
79
|
+
const [moved] = reordered.splice(sourceIndex, 1);
|
|
80
|
+
const adjustedTarget = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex;
|
|
81
|
+
const insertAt = position === 'after' ? adjustedTarget + 1 : adjustedTarget;
|
|
82
|
+
reordered.splice(insertAt, 0, moved);
|
|
83
|
+
|
|
84
|
+
const newOrder = reordered.map(p => p.id);
|
|
85
|
+
handleDragEnd();
|
|
86
|
+
api.reorderPrompts(project, session.sessionId, newOrder).then(onMutate).catch(() => {});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderPromptList(prompts: Prompt[], reorderable: boolean) {
|
|
90
|
+
return prompts.map(prompt => (
|
|
91
|
+
<PromptCard
|
|
92
|
+
key={prompt.id}
|
|
93
|
+
prompt={prompt}
|
|
94
|
+
project={project}
|
|
95
|
+
sessionId={session.sessionId}
|
|
96
|
+
onMutate={onMutate}
|
|
97
|
+
onDragStart={reorderable ? handleDragStart : undefined}
|
|
98
|
+
onDragOver={reorderable ? handleDragOver : undefined}
|
|
99
|
+
onDragEnd={reorderable ? handleDragEnd : undefined}
|
|
100
|
+
onDrop={reorderable ? handleDrop : undefined}
|
|
101
|
+
dropPosition={dragOver?.id === prompt.id ? dragOver.position : null}
|
|
102
|
+
isDragging={draggingId === prompt.id}
|
|
103
|
+
/>
|
|
104
|
+
));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div className="border border-[var(--color-border)] rounded-lg overflow-hidden bg-white/[0.02]">
|
|
109
|
+
{/* Session header */}
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
onClick={() => setExpanded(v => !v)}
|
|
113
|
+
className={[
|
|
114
|
+
'w-full flex items-center gap-3 px-4 py-3 text-left cursor-pointer',
|
|
115
|
+
'hover:bg-white/5 transition-colors duration-150 focus:outline-none',
|
|
116
|
+
].join(' ')}
|
|
117
|
+
aria-expanded={expanded}
|
|
118
|
+
>
|
|
119
|
+
<StatusDot status={session.status} />
|
|
120
|
+
<span className={[
|
|
121
|
+
'flex-1 text-sm truncate leading-tight',
|
|
122
|
+
session.sessionName ? 'text-[var(--color-text)]' : 'text-[var(--color-muted)] italic',
|
|
123
|
+
].join(' ')}>
|
|
124
|
+
{displayName}
|
|
125
|
+
</span>
|
|
126
|
+
{pendingCount > 0 && (
|
|
127
|
+
<span className="text-[10px] px-1.5 py-0.5 rounded font-medium bg-[var(--color-pending)]/15 text-[var(--color-pending)] leading-none">
|
|
128
|
+
{pendingCount} queued
|
|
129
|
+
</span>
|
|
130
|
+
)}
|
|
131
|
+
<span
|
|
132
|
+
className="text-[var(--color-muted)] text-xs transition-transform duration-200"
|
|
133
|
+
style={{ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
|
134
|
+
aria-hidden="true"
|
|
135
|
+
>
|
|
136
|
+
▶
|
|
137
|
+
</span>
|
|
138
|
+
</button>
|
|
139
|
+
|
|
140
|
+
{/* Session content */}
|
|
141
|
+
{expanded && (
|
|
142
|
+
<div className="px-4 pb-4 space-y-2">
|
|
143
|
+
{activePrompts.length === 0 && completedPrompts.length === 0 && (
|
|
144
|
+
<p className="text-xs text-[var(--color-muted)] py-2">No prompts yet</p>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{activePrompts.length > 0 && (
|
|
148
|
+
<div className="space-y-2" role="list" aria-label="Active prompts">
|
|
149
|
+
{renderPromptList(activePrompts, true)}
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
<AddPromptForm project={project} sessionId={session.sessionId} onAdded={onMutate} />
|
|
154
|
+
|
|
155
|
+
{completedPrompts.length > 0 && (
|
|
156
|
+
<div className="pt-1 space-y-2 opacity-60" role="list" aria-label="Completed prompts">
|
|
157
|
+
{renderPromptList(completedPrompts, false)}
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { ProjectView } from '../types/queue';
|
|
2
|
+
|
|
3
|
+
interface SidebarProps {
|
|
4
|
+
projects: ProjectView[];
|
|
5
|
+
selectedProject: string | null;
|
|
6
|
+
onSelectProject: (name: string) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getSessionStatus(project: ProjectView): 'active' | 'idle' | 'none' {
|
|
10
|
+
if (project.sessions.length === 0) return 'none';
|
|
11
|
+
return project.sessions.some(s => s.status === 'active') ? 'active' : 'idle';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getPendingCount(project: ProjectView): number {
|
|
15
|
+
return project.sessions.reduce(
|
|
16
|
+
(sum, s) => sum + s.prompts.filter(p => p.status === 'pending').length,
|
|
17
|
+
0,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function StatusDot({ status }: { status: 'active' | 'idle' | 'none' }) {
|
|
22
|
+
if (status === 'active') {
|
|
23
|
+
return (
|
|
24
|
+
<span
|
|
25
|
+
className="animate-pulse-dot inline-block w-2 h-2 rounded-full bg-[var(--color-active)] shrink-0"
|
|
26
|
+
aria-label="Active session"
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (status === 'idle') {
|
|
31
|
+
return (
|
|
32
|
+
<span
|
|
33
|
+
className="inline-block w-2 h-2 rounded-full bg-[var(--color-idle)] shrink-0"
|
|
34
|
+
aria-label="Idle session"
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return (
|
|
39
|
+
<span
|
|
40
|
+
className="inline-block w-2 h-2 rounded-full bg-[var(--color-muted)] shrink-0"
|
|
41
|
+
aria-label="No session"
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function Sidebar({ projects, selectedProject, onSelectProject }: SidebarProps) {
|
|
47
|
+
return (
|
|
48
|
+
<aside
|
|
49
|
+
className="flex flex-col w-[280px] shrink-0 h-full bg-[var(--color-surface)] border-r border-[var(--color-border)]"
|
|
50
|
+
aria-label="Project navigation"
|
|
51
|
+
>
|
|
52
|
+
{/* Header */}
|
|
53
|
+
<div className="px-5 py-4 border-b border-[var(--color-border)]">
|
|
54
|
+
<h1
|
|
55
|
+
className="text-base font-bold tracking-widest uppercase text-[var(--color-active)]"
|
|
56
|
+
style={{ textShadow: '0 0 12px rgba(74, 222, 128, 0.4)' }}
|
|
57
|
+
>
|
|
58
|
+
PromptLine
|
|
59
|
+
</h1>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{/* Project list */}
|
|
63
|
+
<nav className="flex-1 overflow-y-auto py-2" aria-label="Projects">
|
|
64
|
+
{projects.length === 0 && (
|
|
65
|
+
<p className="px-5 py-4 text-xs text-[var(--color-muted)]">No projects found.</p>
|
|
66
|
+
)}
|
|
67
|
+
<ul role="list">
|
|
68
|
+
{projects.map((project) => {
|
|
69
|
+
const status = getSessionStatus(project);
|
|
70
|
+
const pending = getPendingCount(project);
|
|
71
|
+
const isSelected = project.project === selectedProject;
|
|
72
|
+
const sessionCount = project.sessions.length;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<li key={project.project} role="listitem">
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
onClick={() => onSelectProject(project.project)}
|
|
79
|
+
aria-current={isSelected ? 'page' : undefined}
|
|
80
|
+
className={[
|
|
81
|
+
'w-full text-left px-5 py-3 flex items-start gap-3 transition-colors duration-150 cursor-pointer',
|
|
82
|
+
'border-l-2',
|
|
83
|
+
isSelected
|
|
84
|
+
? 'border-[var(--color-running)] bg-[var(--color-border)]'
|
|
85
|
+
: 'border-transparent hover:bg-white/5',
|
|
86
|
+
].join(' ')}
|
|
87
|
+
>
|
|
88
|
+
<span className="mt-[3px]">
|
|
89
|
+
<StatusDot status={status} />
|
|
90
|
+
</span>
|
|
91
|
+
|
|
92
|
+
<span className="flex flex-col gap-0.5 min-w-0">
|
|
93
|
+
<span className="text-sm font-bold leading-tight truncate text-[var(--color-text)]">
|
|
94
|
+
{project.project}
|
|
95
|
+
</span>
|
|
96
|
+
|
|
97
|
+
<span className="text-xs text-[var(--color-muted)] truncate leading-tight">
|
|
98
|
+
{project.directory}
|
|
99
|
+
</span>
|
|
100
|
+
|
|
101
|
+
<span className="flex items-center gap-2 mt-1">
|
|
102
|
+
{project.queueStatus === 'completed' && (
|
|
103
|
+
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-active)]/15 text-[var(--color-active)] leading-none">
|
|
104
|
+
completed
|
|
105
|
+
</span>
|
|
106
|
+
)}
|
|
107
|
+
{pending > 0 && (
|
|
108
|
+
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-pending)]/15 text-[var(--color-pending)] leading-none">
|
|
109
|
+
{pending} queued
|
|
110
|
+
</span>
|
|
111
|
+
)}
|
|
112
|
+
{sessionCount > 1 && (
|
|
113
|
+
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-white/10 text-[var(--color-muted)] leading-none">
|
|
114
|
+
{sessionCount} sessions
|
|
115
|
+
</span>
|
|
116
|
+
)}
|
|
117
|
+
</span>
|
|
118
|
+
</span>
|
|
119
|
+
</button>
|
|
120
|
+
</li>
|
|
121
|
+
);
|
|
122
|
+
})}
|
|
123
|
+
</ul>
|
|
124
|
+
</nav>
|
|
125
|
+
</aside>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ProjectView } from '../types/queue';
|
|
2
|
+
|
|
3
|
+
interface StatusBarProps {
|
|
4
|
+
projects: ProjectView[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface AggregateStats {
|
|
8
|
+
activeSessions: number;
|
|
9
|
+
queued: number;
|
|
10
|
+
completed: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function computeStats(projects: ProjectView[]): AggregateStats {
|
|
14
|
+
let activeSessions = 0;
|
|
15
|
+
let queued = 0;
|
|
16
|
+
let completed = 0;
|
|
17
|
+
|
|
18
|
+
for (const project of projects) {
|
|
19
|
+
for (const session of project.sessions) {
|
|
20
|
+
if (session.status === 'active') activeSessions += 1;
|
|
21
|
+
for (const prompt of session.prompts) {
|
|
22
|
+
if (prompt.status === 'pending') queued += 1;
|
|
23
|
+
if (prompt.status === 'completed') completed += 1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return { activeSessions, queued, completed };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface StatSegmentProps {
|
|
32
|
+
value: number;
|
|
33
|
+
label: string;
|
|
34
|
+
color: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function StatSegment({ value, label, color }: StatSegmentProps) {
|
|
38
|
+
return (
|
|
39
|
+
<span className="flex items-center gap-1.5">
|
|
40
|
+
<span className="font-bold" style={{ color }}>
|
|
41
|
+
{value}
|
|
42
|
+
</span>
|
|
43
|
+
<span className="text-[var(--color-muted)]">{label}</span>
|
|
44
|
+
</span>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function StatusBar({ projects }: StatusBarProps) {
|
|
49
|
+
const { activeSessions, queued, completed } = computeStats(projects);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<footer
|
|
53
|
+
className="flex items-center gap-4 px-5 h-8 shrink-0 bg-[var(--color-surface)] border-t border-[var(--color-border)]"
|
|
54
|
+
aria-label="Status bar"
|
|
55
|
+
>
|
|
56
|
+
<StatSegment value={activeSessions} label="active" color="var(--color-active)" />
|
|
57
|
+
|
|
58
|
+
<span className="text-[var(--color-border)] select-none" aria-hidden="true">
|
|
59
|
+
·
|
|
60
|
+
</span>
|
|
61
|
+
|
|
62
|
+
<StatSegment value={queued} label="queued" color="var(--color-pending)" />
|
|
63
|
+
|
|
64
|
+
<span className="text-[var(--color-border)] select-none" aria-hidden="true">
|
|
65
|
+
·
|
|
66
|
+
</span>
|
|
67
|
+
|
|
68
|
+
<StatSegment value={completed} label="completed" color="var(--color-completed)" />
|
|
69
|
+
</footer>
|
|
70
|
+
);
|
|
71
|
+
}
|