@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.
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ import type { ProjectView } from '../types/queue';
2
+
3
+ export function selectProject(project: string | null, projects: ProjectView[]): ProjectView | null {
4
+ if (!project) return null;
5
+ return projects.find(p => p.project === project) ?? null;
6
+ }