@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
package/src/App.tsx
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useProjects } from './hooks/useQueues';
|
|
3
|
+
import { Sidebar } from './components/Sidebar';
|
|
4
|
+
import { StatusBar } from './components/StatusBar';
|
|
5
|
+
import { ProjectDetail } from './components/ProjectDetail';
|
|
6
|
+
|
|
7
|
+
function App() {
|
|
8
|
+
const { projects, loading, error } = useProjects();
|
|
9
|
+
const [selectedProject, setSelectedProject] = useState<string | null>(null);
|
|
10
|
+
|
|
11
|
+
function handleProjectDeleted() {
|
|
12
|
+
setSelectedProject(null);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div className="flex flex-col h-screen overflow-hidden bg-[var(--color-bg)] text-[var(--color-text)] font-mono">
|
|
17
|
+
{/* Main area: sidebar + content */}
|
|
18
|
+
<div className="flex flex-1 overflow-hidden">
|
|
19
|
+
<Sidebar
|
|
20
|
+
projects={projects}
|
|
21
|
+
selectedProject={selectedProject}
|
|
22
|
+
onSelectProject={setSelectedProject}
|
|
23
|
+
/>
|
|
24
|
+
|
|
25
|
+
{/* Main content */}
|
|
26
|
+
<main className="flex-1 overflow-hidden bg-[var(--color-bg)]">
|
|
27
|
+
{loading && (
|
|
28
|
+
<div className="flex items-center justify-center h-full">
|
|
29
|
+
<p className="text-sm text-[var(--color-muted)] animate-pulse">Loading...</p>
|
|
30
|
+
</div>
|
|
31
|
+
)}
|
|
32
|
+
|
|
33
|
+
{!loading && error && (
|
|
34
|
+
<div className="flex items-center justify-center h-full">
|
|
35
|
+
<p className="text-sm text-red-400">Error: {error}</p>
|
|
36
|
+
</div>
|
|
37
|
+
)}
|
|
38
|
+
|
|
39
|
+
{!loading && !error && !selectedProject && (
|
|
40
|
+
<div className="flex flex-col items-center justify-center h-full gap-3 select-none opacity-40">
|
|
41
|
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-[var(--color-muted)]">
|
|
42
|
+
<rect x="3" y="3" width="7" height="7" rx="1" />
|
|
43
|
+
<rect x="14" y="3" width="7" height="7" rx="1" />
|
|
44
|
+
<rect x="3" y="14" width="7" height="7" rx="1" />
|
|
45
|
+
<rect x="14" y="14" width="7" height="7" rx="1" />
|
|
46
|
+
</svg>
|
|
47
|
+
<div className="text-center">
|
|
48
|
+
<p className="text-sm text-[var(--color-muted)] font-medium">No project selected</p>
|
|
49
|
+
<p className="text-xs text-[var(--color-muted)] mt-1">Pick one from the sidebar to view its prompt queue</p>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
|
|
54
|
+
{!loading && !error && selectedProject && (
|
|
55
|
+
<ProjectDetail
|
|
56
|
+
project={selectedProject}
|
|
57
|
+
projects={projects}
|
|
58
|
+
onProjectDeleted={handleProjectDeleted}
|
|
59
|
+
/>
|
|
60
|
+
)}
|
|
61
|
+
</main>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
{/* Status bar pinned at bottom */}
|
|
65
|
+
<StatusBar projects={projects} />
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default App;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ProjectView, Prompt } from '../types/queue';
|
|
2
|
+
|
|
3
|
+
const BASE = '/api';
|
|
4
|
+
|
|
5
|
+
async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
|
6
|
+
const res = await fetch(`${BASE}${url}`, {
|
|
7
|
+
headers: { 'Content-Type': 'application/json' },
|
|
8
|
+
...options,
|
|
9
|
+
});
|
|
10
|
+
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
|
11
|
+
return res.json();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function projectUrl(project: string): string {
|
|
15
|
+
return `/projects/${encodeURIComponent(project)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sessionUrl(project: string, sessionId: string): string {
|
|
19
|
+
return `${projectUrl(project)}/sessions/${encodeURIComponent(sessionId)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const api = {
|
|
23
|
+
listProjects: () => request<ProjectView[]>('/projects'),
|
|
24
|
+
|
|
25
|
+
getProject: (project: string) => request<ProjectView>(projectUrl(project)),
|
|
26
|
+
|
|
27
|
+
deleteProject: (project: string) =>
|
|
28
|
+
request<{ deleted: string }>(projectUrl(project), { method: 'DELETE' }),
|
|
29
|
+
|
|
30
|
+
deleteSession: (project: string, sessionId: string) =>
|
|
31
|
+
request<{ deleted: string }>(sessionUrl(project, sessionId), { method: 'DELETE' }),
|
|
32
|
+
|
|
33
|
+
addPrompt: (project: string, sessionId: string, text: string) =>
|
|
34
|
+
request<Prompt>(`${sessionUrl(project, sessionId)}/prompts`, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
body: JSON.stringify({ text }),
|
|
37
|
+
}),
|
|
38
|
+
|
|
39
|
+
updatePrompt: (project: string, sessionId: string, promptId: string, data: { text?: string }) =>
|
|
40
|
+
request<Prompt>(`${sessionUrl(project, sessionId)}/prompts/${encodeURIComponent(promptId)}`, {
|
|
41
|
+
method: 'PATCH',
|
|
42
|
+
body: JSON.stringify(data),
|
|
43
|
+
}),
|
|
44
|
+
|
|
45
|
+
deletePrompt: (project: string, sessionId: string, promptId: string) =>
|
|
46
|
+
request<Prompt>(`${sessionUrl(project, sessionId)}/prompts/${encodeURIComponent(promptId)}`, {
|
|
47
|
+
method: 'DELETE',
|
|
48
|
+
}),
|
|
49
|
+
|
|
50
|
+
reorderPrompts: (project: string, sessionId: string, order: string[]) =>
|
|
51
|
+
request<void>(`${sessionUrl(project, sessionId)}/prompts/reorder`, {
|
|
52
|
+
method: 'PUT',
|
|
53
|
+
body: JSON.stringify({ order }),
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync, renameSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { SessionQueue, Prompt, PromptStatus, SessionStatus, QueueStatus, ProjectView, SessionWithStatus } from '../types/queue.ts';
|
|
4
|
+
|
|
5
|
+
export const SESSION_ACTIVE_TIMEOUT_MS = 60_000;
|
|
6
|
+
export const SESSION_VISIBLE_TIMEOUT_MS = 5 * SESSION_ACTIVE_TIMEOUT_MS;
|
|
7
|
+
|
|
8
|
+
export function ensureProjectDir(queuesDir: string, project: string): void {
|
|
9
|
+
mkdirSync(join(queuesDir, project), { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function sessionPath(queuesDir: string, project: string, sessionId: string): string {
|
|
13
|
+
return join(queuesDir, project, `${sessionId}.json`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function readSession(queuesDir: string, project: string, sessionId: string): SessionQueue | null {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(readFileSync(sessionPath(queuesDir, project, sessionId), 'utf-8')) as SessionQueue;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function writeSession(queuesDir: string, project: string, session: SessionQueue): void {
|
|
25
|
+
ensureProjectDir(queuesDir, project);
|
|
26
|
+
const filePath = sessionPath(queuesDir, project, session.sessionId);
|
|
27
|
+
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
28
|
+
try {
|
|
29
|
+
writeFileSync(tmpPath, JSON.stringify(session, null, 2));
|
|
30
|
+
renameSync(tmpPath, filePath);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
try { unlinkSync(tmpPath); } catch { /* ignore cleanup error */ }
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function msSinceLastActivity(session: SessionQueue, now: number = Date.now()): number {
|
|
38
|
+
return now - new Date(session.lastActivity).getTime();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hasPendingWork(session: SessionQueue): boolean {
|
|
42
|
+
return session.prompts.some(p => p.status === 'pending' || p.status === 'running');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function withComputedStatus(session: SessionQueue): SessionQueue & { status: SessionStatus } {
|
|
46
|
+
const hasRunningPrompt = session.prompts.some(p => p.status === 'running');
|
|
47
|
+
const isStale = msSinceLastActivity(session) > SESSION_ACTIVE_TIMEOUT_MS;
|
|
48
|
+
const status: SessionStatus = (hasRunningPrompt || !isStale) ? 'active' : 'idle';
|
|
49
|
+
return { ...session, status };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isSessionVisible(session: SessionQueue, now: number = Date.now()): boolean {
|
|
53
|
+
if (hasPendingWork(session)) return true;
|
|
54
|
+
if (session.closedAt != null) return false;
|
|
55
|
+
return msSinceLastActivity(session, now) <= SESSION_VISIBLE_TIMEOUT_MS;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function loadProjectView(queuesDir: string, project: string): ProjectView | null {
|
|
59
|
+
const dirPath = join(queuesDir, project);
|
|
60
|
+
let files: string[];
|
|
61
|
+
try {
|
|
62
|
+
files = readdirSync(dirPath).filter(f => f.endsWith('.json'));
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
|
|
69
|
+
const sessions = files
|
|
70
|
+
.map(f => {
|
|
71
|
+
const sessionId = f.replace(/\.json$/, '');
|
|
72
|
+
const raw = readSession(queuesDir, project, sessionId);
|
|
73
|
+
return raw ? withComputedStatus(raw) : null;
|
|
74
|
+
})
|
|
75
|
+
.filter((s): s is NonNullable<typeof s> => s !== null)
|
|
76
|
+
.filter((s: SessionWithStatus) => isSessionVisible(s, now));
|
|
77
|
+
|
|
78
|
+
if (sessions.length === 0) return null;
|
|
79
|
+
|
|
80
|
+
const hasPrompts = sessions.some(s => s.prompts.length > 0);
|
|
81
|
+
const allCompleted = hasPrompts && sessions.every(s =>
|
|
82
|
+
s.prompts.length > 0 && s.prompts.every(p => p.status === 'completed')
|
|
83
|
+
);
|
|
84
|
+
const queueStatus: QueueStatus = allCompleted ? 'completed' : hasPrompts ? 'active' : 'empty';
|
|
85
|
+
|
|
86
|
+
return { project, directory: sessions[0].directory, sessions, queueStatus };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function listProjects(queuesDir: string): ProjectView[] {
|
|
90
|
+
try {
|
|
91
|
+
return readdirSync(queuesDir, { withFileTypes: true })
|
|
92
|
+
.filter(d => d.isDirectory())
|
|
93
|
+
.map(dir => loadProjectView(queuesDir, dir.name))
|
|
94
|
+
.filter((p): p is NonNullable<typeof p> => p !== null);
|
|
95
|
+
} catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getProject(queuesDir: string, project: string): ProjectView | null {
|
|
101
|
+
return loadProjectView(queuesDir, project);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function deleteProject(queuesDir: string, project: string): void {
|
|
105
|
+
rmSync(join(queuesDir, project), { recursive: true });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function deleteSession(queuesDir: string, project: string, sessionId: string): void {
|
|
109
|
+
unlinkSync(sessionPath(queuesDir, project, sessionId));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function addPrompt(session: SessionQueue, id: string, text: string): Prompt {
|
|
113
|
+
const prompt: Prompt = {
|
|
114
|
+
id,
|
|
115
|
+
text,
|
|
116
|
+
status: 'pending',
|
|
117
|
+
createdAt: new Date().toISOString(),
|
|
118
|
+
completedAt: null,
|
|
119
|
+
};
|
|
120
|
+
session.prompts.push(prompt);
|
|
121
|
+
return prompt;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function updatePrompt(
|
|
125
|
+
session: SessionQueue,
|
|
126
|
+
promptId: string,
|
|
127
|
+
updates: { text?: string; status?: PromptStatus },
|
|
128
|
+
): Prompt | null {
|
|
129
|
+
const idx = session.prompts.findIndex(p => p.id === promptId);
|
|
130
|
+
if (idx === -1) return null;
|
|
131
|
+
|
|
132
|
+
if (updates.text !== undefined) {
|
|
133
|
+
session.prompts[idx].text = updates.text;
|
|
134
|
+
}
|
|
135
|
+
if (updates.status !== undefined) {
|
|
136
|
+
session.prompts[idx].status = updates.status;
|
|
137
|
+
if (updates.status === 'completed') {
|
|
138
|
+
session.prompts[idx].completedAt = new Date().toISOString();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return session.prompts[idx];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function deletePrompt(session: SessionQueue, promptId: string): Prompt | null {
|
|
146
|
+
const idx = session.prompts.findIndex(p => p.id === promptId);
|
|
147
|
+
if (idx === -1) return null;
|
|
148
|
+
return session.prompts.splice(idx, 1)[0];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function reorderPrompts(session: SessionQueue, order: string[]): string | null {
|
|
152
|
+
const promptMap = new Map(session.prompts.map(p => [p.id, p]));
|
|
153
|
+
for (const id of order) {
|
|
154
|
+
if (!promptMap.has(id)) {
|
|
155
|
+
return `Prompt "${id}" not found`;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const reordered: Prompt[] = [];
|
|
160
|
+
for (const id of order) {
|
|
161
|
+
reordered.push(promptMap.get(id)!);
|
|
162
|
+
}
|
|
163
|
+
const orderSet = new Set(order);
|
|
164
|
+
for (const p of session.prompts) {
|
|
165
|
+
if (!orderSet.has(p.id)) {
|
|
166
|
+
reordered.push(p);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
session.prompts = reordered;
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { api } from '../api/client';
|
|
3
|
+
|
|
4
|
+
interface AddPromptFormProps {
|
|
5
|
+
project: string;
|
|
6
|
+
sessionId: string;
|
|
7
|
+
onAdded: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function AddPromptForm({ project, sessionId, onAdded }: AddPromptFormProps) {
|
|
11
|
+
const [expanded, setExpanded] = useState(false);
|
|
12
|
+
const [text, setText] = useState('');
|
|
13
|
+
const [submitting, setSubmitting] = useState(false);
|
|
14
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (expanded && textareaRef.current) {
|
|
18
|
+
textareaRef.current.focus();
|
|
19
|
+
}
|
|
20
|
+
}, [expanded]);
|
|
21
|
+
|
|
22
|
+
// Auto-expand textarea height
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const el = textareaRef.current;
|
|
25
|
+
if (!el) return;
|
|
26
|
+
el.style.height = 'auto';
|
|
27
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
28
|
+
}, [text]);
|
|
29
|
+
|
|
30
|
+
function handleCancel() {
|
|
31
|
+
setText('');
|
|
32
|
+
setExpanded(false);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function handleSubmit() {
|
|
36
|
+
const trimmed = text.trim();
|
|
37
|
+
if (!trimmed || submitting) return;
|
|
38
|
+
setSubmitting(true);
|
|
39
|
+
try {
|
|
40
|
+
await api.addPrompt(project, sessionId, trimmed);
|
|
41
|
+
setText('');
|
|
42
|
+
setExpanded(false);
|
|
43
|
+
onAdded();
|
|
44
|
+
} catch {
|
|
45
|
+
// Keep form open so user can retry
|
|
46
|
+
} finally {
|
|
47
|
+
setSubmitting(false);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
|
52
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
handleSubmit();
|
|
55
|
+
}
|
|
56
|
+
if (e.key === 'Escape') {
|
|
57
|
+
handleCancel();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!expanded) {
|
|
62
|
+
return (
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
onClick={() => setExpanded(true)}
|
|
66
|
+
className={[
|
|
67
|
+
'w-full text-left text-xs text-[var(--color-muted)] px-4 py-2.5 cursor-pointer',
|
|
68
|
+
'border border-dashed border-[var(--color-border)] rounded-lg',
|
|
69
|
+
'hover:border-[var(--color-active)]/40 hover:text-[var(--color-active)] hover:bg-[var(--color-active)]/5',
|
|
70
|
+
'transition-all duration-150 focus:outline-none focus:ring-1 focus:ring-[var(--color-active)]/30',
|
|
71
|
+
].join(' ')}
|
|
72
|
+
aria-label="Add a new prompt"
|
|
73
|
+
>
|
|
74
|
+
+ Add Prompt
|
|
75
|
+
</button>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
className="border border-[var(--color-active)]/30 rounded-lg bg-[var(--color-active)]/5 overflow-hidden"
|
|
82
|
+
role="form"
|
|
83
|
+
aria-label="Add prompt form"
|
|
84
|
+
>
|
|
85
|
+
<textarea
|
|
86
|
+
ref={textareaRef}
|
|
87
|
+
value={text}
|
|
88
|
+
onChange={(e) => setText(e.target.value)}
|
|
89
|
+
onKeyDown={handleKeyDown}
|
|
90
|
+
rows={2}
|
|
91
|
+
placeholder="Type your prompt... (Enter to submit, Shift+Enter for newline)"
|
|
92
|
+
className={[
|
|
93
|
+
'w-full bg-transparent text-sm text-[var(--color-text)] placeholder:text-[var(--color-muted)]/60',
|
|
94
|
+
'px-4 pt-3 pb-2 resize-none outline-none leading-relaxed',
|
|
95
|
+
'min-h-[3.5rem]',
|
|
96
|
+
].join(' ')}
|
|
97
|
+
aria-label="Prompt text"
|
|
98
|
+
disabled={submitting}
|
|
99
|
+
/>
|
|
100
|
+
<div className="flex items-center justify-end gap-2 px-4 pb-3">
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
onClick={handleCancel}
|
|
104
|
+
disabled={submitting}
|
|
105
|
+
className={[
|
|
106
|
+
'text-xs px-3 py-1.5 rounded border border-[var(--color-border)] text-[var(--color-muted)] cursor-pointer',
|
|
107
|
+
'hover:border-white/20 hover:text-[var(--color-text)] transition-all duration-150',
|
|
108
|
+
'focus:outline-none focus:ring-1 focus:ring-white/20',
|
|
109
|
+
'disabled:opacity-40 disabled:cursor-not-allowed',
|
|
110
|
+
].join(' ')}
|
|
111
|
+
>
|
|
112
|
+
Cancel
|
|
113
|
+
</button>
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
onClick={handleSubmit}
|
|
117
|
+
disabled={!text.trim() || submitting}
|
|
118
|
+
className={[
|
|
119
|
+
'text-xs px-3 py-1.5 rounded font-medium transition-all duration-150 cursor-pointer',
|
|
120
|
+
'bg-[var(--color-active)]/15 text-[var(--color-active)] border border-[var(--color-active)]/30',
|
|
121
|
+
'hover:bg-[var(--color-active)]/25 hover:border-[var(--color-active)]/50',
|
|
122
|
+
'focus:outline-none focus:ring-1 focus:ring-[var(--color-active)]/50',
|
|
123
|
+
'disabled:opacity-40 disabled:cursor-not-allowed',
|
|
124
|
+
].join(' ')}
|
|
125
|
+
>
|
|
126
|
+
{submitting ? 'Adding...' : 'Add'}
|
|
127
|
+
</button>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { selectProject } from '../hooks/useQueue';
|
|
3
|
+
import { api } from '../api/client';
|
|
4
|
+
import type { ProjectView, SessionWithStatus } from '../types/queue';
|
|
5
|
+
import { SessionSection } from './SessionSection';
|
|
6
|
+
|
|
7
|
+
interface ProjectDetailProps {
|
|
8
|
+
project: string;
|
|
9
|
+
projects: ProjectView[];
|
|
10
|
+
onProjectDeleted: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isVisible(session: SessionWithStatus): boolean {
|
|
14
|
+
if (session.status === 'active') return true;
|
|
15
|
+
return session.prompts.some(p => p.status === 'pending' || p.status === 'running');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ProjectDetail({ project, projects, onProjectDeleted }: ProjectDetailProps) {
|
|
19
|
+
const projectView = selectProject(project, projects);
|
|
20
|
+
const [historyOpen, setHistoryOpen] = useState(false);
|
|
21
|
+
|
|
22
|
+
async function handleDeleteProject() {
|
|
23
|
+
const confirmed = window.confirm(
|
|
24
|
+
`Delete project "${project}"? This removes all sessions and prompts.`
|
|
25
|
+
);
|
|
26
|
+
if (!confirmed) return;
|
|
27
|
+
try {
|
|
28
|
+
await api.deleteProject(project);
|
|
29
|
+
onProjectDeleted();
|
|
30
|
+
} catch {
|
|
31
|
+
// Silent fail
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!projectView) return null;
|
|
36
|
+
|
|
37
|
+
const visibleSessions = projectView.sessions.filter(isVisible);
|
|
38
|
+
const historySessions = projectView.sessions.filter(s => !isVisible(s));
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="flex flex-col h-full overflow-hidden">
|
|
42
|
+
{/* Header */}
|
|
43
|
+
<div className="shrink-0 px-6 pt-6 pb-4 border-b border-[var(--color-border)] bg-[var(--color-surface)]">
|
|
44
|
+
<div className="flex items-start justify-between gap-4">
|
|
45
|
+
<div className="min-w-0">
|
|
46
|
+
<h2 className="text-lg font-bold text-[var(--color-text)] truncate leading-tight">
|
|
47
|
+
{projectView.project}
|
|
48
|
+
</h2>
|
|
49
|
+
<p className="text-xs text-[var(--color-muted)] mt-0.5 truncate font-mono">
|
|
50
|
+
{projectView.directory}
|
|
51
|
+
</p>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
onClick={handleDeleteProject}
|
|
57
|
+
className={[
|
|
58
|
+
'shrink-0 text-xs px-3 py-1.5 rounded border transition-all duration-150 cursor-pointer',
|
|
59
|
+
'border-red-900/40 text-red-500/60',
|
|
60
|
+
'hover:border-red-500/60 hover:text-red-400 hover:bg-red-500/5',
|
|
61
|
+
'focus:outline-none focus:ring-1 focus:ring-red-500/30',
|
|
62
|
+
].join(' ')}
|
|
63
|
+
aria-label={`Delete project ${projectView.project}`}
|
|
64
|
+
>
|
|
65
|
+
Delete Project
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
{/* Scrollable content */}
|
|
71
|
+
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-3">
|
|
72
|
+
{visibleSessions.length === 0 && historySessions.length === 0 && (
|
|
73
|
+
<div className="flex flex-col items-center gap-3 py-12 select-none opacity-40">
|
|
74
|
+
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-[var(--color-muted)]">
|
|
75
|
+
<path d="M12 20h9" />
|
|
76
|
+
<path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
|
77
|
+
</svg>
|
|
78
|
+
<div className="text-center">
|
|
79
|
+
<p className="text-sm text-[var(--color-muted)] font-medium">No sessions yet</p>
|
|
80
|
+
<p className="text-xs text-[var(--color-muted)] mt-1">Start a Claude Code session in this project to begin</p>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
{visibleSessions.map(session => (
|
|
86
|
+
<SessionSection
|
|
87
|
+
key={session.sessionId}
|
|
88
|
+
session={session}
|
|
89
|
+
project={project}
|
|
90
|
+
onMutate={() => {}}
|
|
91
|
+
defaultExpanded
|
|
92
|
+
/>
|
|
93
|
+
))}
|
|
94
|
+
|
|
95
|
+
{historySessions.length > 0 && (
|
|
96
|
+
<div className="pt-2">
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
onClick={() => setHistoryOpen(v => !v)}
|
|
100
|
+
className={[
|
|
101
|
+
'flex items-center gap-2 text-xs text-[var(--color-muted)] uppercase tracking-wider py-1 cursor-pointer',
|
|
102
|
+
'hover:text-[var(--color-text)] transition-colors duration-150 focus:outline-none',
|
|
103
|
+
].join(' ')}
|
|
104
|
+
aria-expanded={historyOpen}
|
|
105
|
+
>
|
|
106
|
+
<span
|
|
107
|
+
className="inline-block transition-transform duration-200"
|
|
108
|
+
style={{ transform: historyOpen ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
|
109
|
+
aria-hidden="true"
|
|
110
|
+
>
|
|
111
|
+
▶
|
|
112
|
+
</span>
|
|
113
|
+
History ({historySessions.length} {historySessions.length === 1 ? 'session' : 'sessions'})
|
|
114
|
+
</button>
|
|
115
|
+
|
|
116
|
+
{historyOpen && (
|
|
117
|
+
<div className="mt-2 space-y-3">
|
|
118
|
+
{historySessions.map(session => (
|
|
119
|
+
<SessionSection
|
|
120
|
+
key={session.sessionId}
|
|
121
|
+
session={session}
|
|
122
|
+
project={project}
|
|
123
|
+
onMutate={() => {}}
|
|
124
|
+
defaultExpanded={false}
|
|
125
|
+
/>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
<div className="h-4" aria-hidden="true" />
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|