@jxtools/promptline 1.3.18 → 1.3.20
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/bin/install-hooks.mjs +99 -0
- package/bin/promptline.mjs +13 -54
- package/package.json +10 -11
- package/promptline-session-register.sh +1 -0
- package/src/App.tsx +31 -8
- package/src/api/client.ts +4 -1
- package/src/backend/queue-store.ts +7 -3
- package/src/components/AddPromptForm.tsx +10 -4
- package/src/components/InlineAlert.tsx +16 -0
- package/src/components/ProjectDetail.tsx +14 -53
- package/src/components/PromptCard.tsx +15 -7
- package/src/components/SessionSection.tsx +20 -24
- package/src/components/Sidebar.tsx +22 -32
- package/src/components/StatusDot.tsx +29 -0
- package/src/hooks/useQueues.ts +3 -1
- package/src/hooks/useSSE.ts +5 -12
- package/src/utils/errors.ts +3 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
|
|
5
|
+
export function toErrorMessage(error, fallback = 'Unknown error') {
|
|
6
|
+
return error instanceof Error && error.message ? error.message : fallback
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function hasCommand(command, spawnSyncImpl = spawnSync) {
|
|
10
|
+
const result = spawnSyncImpl(command, ['--version'], { stdio: 'ignore' })
|
|
11
|
+
return !result.error && result.status === 0
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function loadSettings(settingsPath) {
|
|
15
|
+
if (!existsSync(settingsPath)) {
|
|
16
|
+
return {}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(settingsPath, 'utf-8'))
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw new Error(`Claude settings file is corrupted: ${toErrorMessage(error)}`)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function writeJsonAtomic(settingsPath, data) {
|
|
27
|
+
const tmpPath = `${settingsPath}.tmp`
|
|
28
|
+
const backupPath = `${settingsPath}.bak`
|
|
29
|
+
|
|
30
|
+
if (existsSync(settingsPath)) {
|
|
31
|
+
copyFileSync(settingsPath, backupPath)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n')
|
|
36
|
+
renameSync(tmpPath, settingsPath)
|
|
37
|
+
} catch (error) {
|
|
38
|
+
try {
|
|
39
|
+
unlinkSync(tmpPath)
|
|
40
|
+
} catch {
|
|
41
|
+
// ignore cleanup errors
|
|
42
|
+
}
|
|
43
|
+
throw error
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function installHooks({ claudeDir, hooksDir, pkgDir, hookFiles, commandAvailable = hasCommand }) {
|
|
48
|
+
if (!commandAvailable('python3')) {
|
|
49
|
+
throw new Error('python3 is required to install PromptLine hooks')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
mkdirSync(hooksDir, { recursive: true })
|
|
53
|
+
|
|
54
|
+
for (const file of hookFiles) {
|
|
55
|
+
const src = join(pkgDir, file)
|
|
56
|
+
const dest = join(hooksDir, file)
|
|
57
|
+
copyFileSync(src, dest)
|
|
58
|
+
chmodSync(dest, 0o755)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const settingsPath = join(claudeDir, 'settings.json')
|
|
62
|
+
const settings = loadSettings(settingsPath)
|
|
63
|
+
|
|
64
|
+
if (!settings.hooks) {
|
|
65
|
+
settings.hooks = {}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const hookConfig = {
|
|
69
|
+
SessionStart: { file: 'promptline-session-register.sh' },
|
|
70
|
+
Stop: { file: 'promptline-prompt-queue.sh' },
|
|
71
|
+
SessionEnd: { file: 'promptline-session-end.sh' },
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let changed = false
|
|
75
|
+
|
|
76
|
+
for (const [event, config] of Object.entries(hookConfig)) {
|
|
77
|
+
const command = `~/.claude/hooks/${config.file}`
|
|
78
|
+
|
|
79
|
+
if (!settings.hooks[event]) {
|
|
80
|
+
settings.hooks[event] = []
|
|
81
|
+
changed = true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const alreadyExists = settings.hooks[event].some((entry) =>
|
|
85
|
+
entry.hooks?.some((hook) => hook.command === command),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if (!alreadyExists) {
|
|
89
|
+
settings.hooks[event].push({
|
|
90
|
+
hooks: [{ type: 'command', command }],
|
|
91
|
+
})
|
|
92
|
+
changed = true
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (changed) {
|
|
97
|
+
writeJsonAtomic(settingsPath, settings)
|
|
98
|
+
}
|
|
99
|
+
}
|
package/bin/promptline.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { existsSync, readFileSync, writeFileSync,
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, renameSync } from 'fs'
|
|
4
4
|
import { resolve, dirname, join } from 'path'
|
|
5
5
|
import { fileURLToPath } from 'url'
|
|
6
6
|
import { spawn, execSync } from 'child_process'
|
|
7
7
|
import { homedir } from 'os'
|
|
8
|
+
import { installHooks as installPromptlineHooks, toErrorMessage } from './install-hooks.mjs'
|
|
8
9
|
|
|
9
10
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
10
11
|
const pkgDir = resolve(__dirname, '..')
|
|
@@ -36,7 +37,7 @@ if (process.argv[2] === 'update') {
|
|
|
36
37
|
execSync('npm install -g @jxtools/promptline@latest', { stdio: 'inherit' })
|
|
37
38
|
console.log(`\n\x1b[32m✓\x1b[0m Updated to v${latest}`)
|
|
38
39
|
} catch (err) {
|
|
39
|
-
console.error(`\x1b[31m✗\x1b[0m Update failed: ${err
|
|
40
|
+
console.error(`\x1b[31m✗\x1b[0m Update failed: ${toErrorMessage(err)}`)
|
|
40
41
|
process.exit(1)
|
|
41
42
|
}
|
|
42
43
|
|
|
@@ -150,57 +151,15 @@ function cancelAllPendingPrompts() {
|
|
|
150
151
|
// --- Helpers ---
|
|
151
152
|
|
|
152
153
|
function installHooks() {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
// Merge into settings.json
|
|
164
|
-
const settingsPath = join(claudeDir, 'settings.json')
|
|
165
|
-
let settings = {}
|
|
166
|
-
|
|
167
|
-
if (existsSync(settingsPath)) {
|
|
168
|
-
try {
|
|
169
|
-
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'))
|
|
170
|
-
} catch {
|
|
171
|
-
// corrupted settings, start fresh
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
if (!settings.hooks) settings.hooks = {}
|
|
176
|
-
|
|
177
|
-
const hookConfig = {
|
|
178
|
-
SessionStart: {
|
|
179
|
-
file: 'promptline-session-register.sh',
|
|
180
|
-
},
|
|
181
|
-
Stop: {
|
|
182
|
-
file: 'promptline-prompt-queue.sh',
|
|
183
|
-
},
|
|
184
|
-
SessionEnd: {
|
|
185
|
-
file: 'promptline-session-end.sh',
|
|
186
|
-
},
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
for (const [event, config] of Object.entries(hookConfig)) {
|
|
190
|
-
const command = `~/.claude/hooks/${config.file}`
|
|
191
|
-
|
|
192
|
-
if (!settings.hooks[event]) settings.hooks[event] = []
|
|
193
|
-
|
|
194
|
-
const alreadyExists = settings.hooks[event].some(entry =>
|
|
195
|
-
entry.hooks?.some(h => h.command === command)
|
|
196
|
-
)
|
|
197
|
-
|
|
198
|
-
if (!alreadyExists) {
|
|
199
|
-
settings.hooks[event].push({
|
|
200
|
-
hooks: [{ type: 'command', command }],
|
|
201
|
-
})
|
|
202
|
-
}
|
|
154
|
+
try {
|
|
155
|
+
installPromptlineHooks({
|
|
156
|
+
claudeDir,
|
|
157
|
+
hooksDir,
|
|
158
|
+
pkgDir,
|
|
159
|
+
hookFiles,
|
|
160
|
+
})
|
|
161
|
+
} catch (error) {
|
|
162
|
+
console.error(`\x1b[31m✗\x1b[0m ${toErrorMessage(error)}`)
|
|
163
|
+
process.exit(1)
|
|
203
164
|
}
|
|
204
|
-
|
|
205
|
-
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n')
|
|
206
165
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxtools/promptline",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.20",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"bin": {
|
|
@@ -29,31 +29,30 @@
|
|
|
29
29
|
"dev": "vite",
|
|
30
30
|
"build": "tsc -b && vite build",
|
|
31
31
|
"lint": "eslint .",
|
|
32
|
-
"preview": "vite preview"
|
|
33
|
-
"test": "vitest run",
|
|
34
|
-
"test:watch": "vitest"
|
|
32
|
+
"preview": "vite preview"
|
|
35
33
|
},
|
|
36
34
|
"dependencies": {
|
|
37
|
-
"@tailwindcss/vite": "^4.2.1",
|
|
38
|
-
"@vitejs/plugin-react": "^5.1.1",
|
|
39
35
|
"react": "^19.2.0",
|
|
40
36
|
"react-dom": "^19.2.0",
|
|
41
|
-
"tailwindcss": "^4.2.1",
|
|
42
|
-
"typescript": "~5.9.3",
|
|
43
37
|
"uuid": "^13.0.0",
|
|
44
38
|
"vite": "^7.3.1"
|
|
45
39
|
},
|
|
46
40
|
"devDependencies": {
|
|
41
|
+
"@tailwindcss/vite": "^4.2.1",
|
|
47
42
|
"@eslint/js": "^9.39.1",
|
|
48
43
|
"@types/node": "^24.10.1",
|
|
49
44
|
"@types/react": "^19.2.7",
|
|
50
45
|
"@types/react-dom": "^19.2.3",
|
|
51
|
-
"@
|
|
46
|
+
"@vitejs/plugin-react": "^5.1.1",
|
|
52
47
|
"eslint": "^9.39.1",
|
|
53
48
|
"eslint-plugin-react-hooks": "^7.0.1",
|
|
54
49
|
"eslint-plugin-react-refresh": "^0.4.24",
|
|
55
50
|
"globals": "^16.5.0",
|
|
56
|
-
"
|
|
57
|
-
"
|
|
51
|
+
"tailwindcss": "^4.2.1",
|
|
52
|
+
"typescript": "~5.9.3",
|
|
53
|
+
"typescript-eslint": "^8.48.0"
|
|
54
|
+
},
|
|
55
|
+
"overrides": {
|
|
56
|
+
"flatted": "^3.4.2"
|
|
58
57
|
}
|
|
59
58
|
}
|
|
@@ -109,6 +109,7 @@ if os.path.isfile(queue_file):
|
|
|
109
109
|
with open(queue_file, "r") as f:
|
|
110
110
|
data = json.load(f)
|
|
111
111
|
data["lastActivity"] = now
|
|
112
|
+
data["closedAt"] = None
|
|
112
113
|
if not data.get("sessionName"):
|
|
113
114
|
data["sessionName"] = extract_session_name(transcript_path)
|
|
114
115
|
if owner_pid is not None and owner_pid > 0:
|
package/src/App.tsx
CHANGED
|
@@ -5,8 +5,11 @@ import { StatusBar } from './components/StatusBar';
|
|
|
5
5
|
import { ProjectDetail } from './components/ProjectDetail';
|
|
6
6
|
|
|
7
7
|
function App() {
|
|
8
|
-
const { projects, loading, error } = useProjects();
|
|
8
|
+
const { projects, loading, error, refresh } = useProjects();
|
|
9
9
|
const [selectedProject, setSelectedProject] = useState<string | null>(null);
|
|
10
|
+
const hasProjects = projects.length > 0;
|
|
11
|
+
const showBlockingError = Boolean(error) && !hasProjects;
|
|
12
|
+
const showBlockingLoading = loading && !hasProjects && !error;
|
|
10
13
|
|
|
11
14
|
function handleProjectDeleted() {
|
|
12
15
|
setSelectedProject(null);
|
|
@@ -24,19 +27,38 @@ function App() {
|
|
|
24
27
|
|
|
25
28
|
{/* Main content */}
|
|
26
29
|
<main className="flex-1 overflow-hidden bg-[var(--color-bg)]">
|
|
27
|
-
{
|
|
28
|
-
<div className="flex items-center justify-center h-full">
|
|
29
|
-
<
|
|
30
|
+
{showBlockingError && (
|
|
31
|
+
<div className="flex items-center justify-center h-full px-6">
|
|
32
|
+
<div
|
|
33
|
+
className="flex flex-col items-center gap-3 text-center"
|
|
34
|
+
role="alert"
|
|
35
|
+
aria-live="assertive"
|
|
36
|
+
>
|
|
37
|
+
<p className="text-sm text-red-400">Error: {error}</p>
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
onClick={() => void refresh()}
|
|
41
|
+
className="text-xs px-3 py-1.5 rounded border border-red-500/30 text-red-300 hover:bg-red-500/10 focus:outline-none"
|
|
42
|
+
>
|
|
43
|
+
Retry
|
|
44
|
+
</button>
|
|
45
|
+
</div>
|
|
30
46
|
</div>
|
|
31
47
|
)}
|
|
32
48
|
|
|
33
|
-
{
|
|
49
|
+
{showBlockingLoading && (
|
|
34
50
|
<div className="flex items-center justify-center h-full">
|
|
35
|
-
<p
|
|
51
|
+
<p
|
|
52
|
+
className="text-sm text-[var(--color-muted)] animate-pulse"
|
|
53
|
+
role="status"
|
|
54
|
+
aria-live="polite"
|
|
55
|
+
>
|
|
56
|
+
Loading...
|
|
57
|
+
</p>
|
|
36
58
|
</div>
|
|
37
59
|
)}
|
|
38
60
|
|
|
39
|
-
{!
|
|
61
|
+
{!showBlockingError && !showBlockingLoading && !selectedProject && (
|
|
40
62
|
<div className="flex flex-col items-center justify-center h-full gap-3 select-none opacity-40">
|
|
41
63
|
<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
64
|
<rect x="3" y="3" width="7" height="7" rx="1" />
|
|
@@ -51,11 +73,12 @@ function App() {
|
|
|
51
73
|
</div>
|
|
52
74
|
)}
|
|
53
75
|
|
|
54
|
-
{!
|
|
76
|
+
{!showBlockingError && !showBlockingLoading && selectedProject && (
|
|
55
77
|
<ProjectDetail
|
|
56
78
|
project={selectedProject}
|
|
57
79
|
projects={projects}
|
|
58
80
|
onProjectDeleted={handleProjectDeleted}
|
|
81
|
+
onMutate={refresh}
|
|
59
82
|
/>
|
|
60
83
|
)}
|
|
61
84
|
</main>
|
package/src/api/client.ts
CHANGED
|
@@ -7,7 +7,10 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
|
|
7
7
|
headers: { 'Content-Type': 'application/json' },
|
|
8
8
|
...options,
|
|
9
9
|
});
|
|
10
|
-
if (!res.ok)
|
|
10
|
+
if (!res.ok) {
|
|
11
|
+
const body = await res.json().catch(() => ({})) as { error?: string };
|
|
12
|
+
throw new Error(body.error ?? `API error: ${res.status}`);
|
|
13
|
+
}
|
|
11
14
|
return res.json();
|
|
12
15
|
}
|
|
13
16
|
|
|
@@ -5,6 +5,8 @@ import type { SessionQueue, Prompt, PromptStatus, SessionStatus, QueueStatus, Pr
|
|
|
5
5
|
export const SESSION_ACTIVE_TIMEOUT_MS = 60_000;
|
|
6
6
|
export const SESSION_ABANDONED_TIMEOUT_MS = 24 * 60 * 60_000;
|
|
7
7
|
const LOCK_STALE_MS = 10_000;
|
|
8
|
+
const LOCK_RETRY_MS = 10;
|
|
9
|
+
const LOCK_WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
8
10
|
|
|
9
11
|
function acquireLockSync(lockPath: string): void {
|
|
10
12
|
for (let i = 0; i < 100; i++) {
|
|
@@ -20,8 +22,7 @@ function acquireLockSync(lockPath: string): void {
|
|
|
20
22
|
continue;
|
|
21
23
|
}
|
|
22
24
|
} catch { continue; }
|
|
23
|
-
|
|
24
|
-
while (Date.now() < end) { /* spin */ }
|
|
25
|
+
Atomics.wait(LOCK_WAIT_BUFFER, 0, 0, LOCK_RETRY_MS);
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
@@ -82,6 +83,9 @@ function hasPendingWork(session: SessionQueue): boolean {
|
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
export function withComputedStatus(session: SessionQueue): SessionQueue & { status: SessionStatus } {
|
|
86
|
+
if (session.closedAt != null) {
|
|
87
|
+
return { ...session, status: 'idle' };
|
|
88
|
+
}
|
|
85
89
|
const hasRunningPrompt = session.prompts.some(p => p.status === 'running');
|
|
86
90
|
const isStale = msSinceLastActivity(session) > SESSION_ACTIVE_TIMEOUT_MS;
|
|
87
91
|
const status: SessionStatus = (hasRunningPrompt || !isStale) ? 'active' : 'idle';
|
|
@@ -89,8 +93,8 @@ export function withComputedStatus(session: SessionQueue): SessionQueue & { stat
|
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
export function isSessionVisible(session: SessionQueue, now: number = Date.now()): boolean {
|
|
92
|
-
if (hasPendingWork(session)) return true;
|
|
93
96
|
if (session.closedAt != null) return false;
|
|
97
|
+
if (hasPendingWork(session)) return true;
|
|
94
98
|
const msSinceStart = now - new Date(session.startedAt).getTime();
|
|
95
99
|
return msSinceStart <= SESSION_ABANDONED_TIMEOUT_MS;
|
|
96
100
|
}
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { useState, useRef, useEffect } from 'react';
|
|
2
2
|
import { api } from '../api/client';
|
|
3
|
+
import { InlineAlert } from './InlineAlert';
|
|
4
|
+
import { toErrorMessage } from '../utils/errors';
|
|
3
5
|
|
|
4
6
|
interface AddPromptFormProps {
|
|
5
7
|
project: string;
|
|
6
8
|
sessionId: string;
|
|
7
|
-
onAdded: () => void
|
|
9
|
+
onAdded: () => void | Promise<void>;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
export function AddPromptForm({ project, sessionId, onAdded }: AddPromptFormProps) {
|
|
11
13
|
const [expanded, setExpanded] = useState(false);
|
|
12
14
|
const [text, setText] = useState('');
|
|
13
15
|
const [submitting, setSubmitting] = useState(false);
|
|
16
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
14
17
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
15
18
|
|
|
16
19
|
useEffect(() => {
|
|
@@ -30,19 +33,21 @@ export function AddPromptForm({ project, sessionId, onAdded }: AddPromptFormProp
|
|
|
30
33
|
function handleCancel() {
|
|
31
34
|
setText('');
|
|
32
35
|
setExpanded(false);
|
|
36
|
+
setActionError(null);
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
async function handleSubmit() {
|
|
36
40
|
const trimmed = text.trim();
|
|
37
41
|
if (!trimmed || submitting) return;
|
|
38
42
|
setSubmitting(true);
|
|
43
|
+
setActionError(null);
|
|
39
44
|
try {
|
|
40
45
|
await api.addPrompt(project, sessionId, trimmed);
|
|
41
46
|
setText('');
|
|
42
47
|
setExpanded(false);
|
|
43
|
-
onAdded();
|
|
44
|
-
} catch {
|
|
45
|
-
|
|
48
|
+
await onAdded();
|
|
49
|
+
} catch (error) {
|
|
50
|
+
setActionError(toErrorMessage(error));
|
|
46
51
|
} finally {
|
|
47
52
|
setSubmitting(false);
|
|
48
53
|
}
|
|
@@ -97,6 +102,7 @@ export function AddPromptForm({ project, sessionId, onAdded }: AddPromptFormProp
|
|
|
97
102
|
aria-label="Prompt text"
|
|
98
103
|
disabled={submitting}
|
|
99
104
|
/>
|
|
105
|
+
{actionError && <InlineAlert message={actionError} className="px-4 pt-2" />}
|
|
100
106
|
<div className="flex items-center justify-end gap-2 px-4 pb-3">
|
|
101
107
|
<button
|
|
102
108
|
type="button"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
interface InlineAlertProps {
|
|
2
|
+
message: string;
|
|
3
|
+
className?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function InlineAlert({ message, className = '' }: InlineAlertProps) {
|
|
7
|
+
return (
|
|
8
|
+
<p
|
|
9
|
+
role="alert"
|
|
10
|
+
aria-live="assertive"
|
|
11
|
+
className={['text-xs text-red-400', className].join(' ').trim()}
|
|
12
|
+
>
|
|
13
|
+
{message}
|
|
14
|
+
</p>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -1,42 +1,39 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import { selectProject } from '../hooks/useQueue';
|
|
3
3
|
import { api } from '../api/client';
|
|
4
|
-
import type { ProjectView
|
|
4
|
+
import type { ProjectView } from '../types/queue';
|
|
5
5
|
import { SessionSection } from './SessionSection';
|
|
6
|
+
import { InlineAlert } from './InlineAlert';
|
|
7
|
+
import { toErrorMessage } from '../utils/errors';
|
|
6
8
|
|
|
7
9
|
interface ProjectDetailProps {
|
|
8
10
|
project: string;
|
|
9
11
|
projects: ProjectView[];
|
|
10
12
|
onProjectDeleted: () => void;
|
|
13
|
+
onMutate: () => void | Promise<void>;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
function
|
|
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) {
|
|
16
|
+
export function ProjectDetail({ project, projects, onProjectDeleted, onMutate }: ProjectDetailProps) {
|
|
19
17
|
const projectView = selectProject(project, projects);
|
|
20
|
-
const [
|
|
18
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
21
19
|
|
|
22
20
|
async function handleDeleteProject() {
|
|
23
21
|
const confirmed = window.confirm(
|
|
24
22
|
`Delete project "${project}"? This removes all sessions and prompts.`
|
|
25
23
|
);
|
|
26
24
|
if (!confirmed) return;
|
|
25
|
+
setActionError(null);
|
|
27
26
|
try {
|
|
28
27
|
await api.deleteProject(project);
|
|
29
28
|
onProjectDeleted();
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
await onMutate();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
setActionError(toErrorMessage(error));
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
if (!projectView) return null;
|
|
36
36
|
|
|
37
|
-
const visibleSessions = projectView.sessions.filter(isVisible);
|
|
38
|
-
const historySessions = projectView.sessions.filter(s => !isVisible(s));
|
|
39
|
-
|
|
40
37
|
return (
|
|
41
38
|
<div className="flex flex-col h-full overflow-hidden">
|
|
42
39
|
{/* Header */}
|
|
@@ -65,11 +62,12 @@ export function ProjectDetail({ project, projects, onProjectDeleted }: ProjectDe
|
|
|
65
62
|
Delete Project
|
|
66
63
|
</button>
|
|
67
64
|
</div>
|
|
65
|
+
{actionError && <InlineAlert message={actionError} className="mt-3" />}
|
|
68
66
|
</div>
|
|
69
67
|
|
|
70
68
|
{/* Scrollable content */}
|
|
71
69
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-3">
|
|
72
|
-
{
|
|
70
|
+
{projectView.sessions.length === 0 && (
|
|
73
71
|
<div className="flex flex-col items-center gap-3 py-12 select-none opacity-40">
|
|
74
72
|
<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
73
|
<path d="M12 20h9" />
|
|
@@ -82,53 +80,16 @@ export function ProjectDetail({ project, projects, onProjectDeleted }: ProjectDe
|
|
|
82
80
|
</div>
|
|
83
81
|
)}
|
|
84
82
|
|
|
85
|
-
{
|
|
83
|
+
{projectView.sessions.map(session => (
|
|
86
84
|
<SessionSection
|
|
87
85
|
key={session.sessionId}
|
|
88
86
|
session={session}
|
|
89
87
|
project={project}
|
|
90
|
-
onMutate={
|
|
88
|
+
onMutate={onMutate}
|
|
91
89
|
defaultExpanded
|
|
92
90
|
/>
|
|
93
91
|
))}
|
|
94
92
|
|
|
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
93
|
<div className="h-4" aria-hidden="true" />
|
|
133
94
|
</div>
|
|
134
95
|
</div>
|
|
@@ -2,12 +2,14 @@ import { useState, useRef, useEffect } from 'react';
|
|
|
2
2
|
import type { Prompt } from '../types/queue';
|
|
3
3
|
import { api } from '../api/client';
|
|
4
4
|
import { TrashIcon } from './TrashIcon';
|
|
5
|
+
import { InlineAlert } from './InlineAlert';
|
|
6
|
+
import { toErrorMessage } from '../utils/errors';
|
|
5
7
|
|
|
6
8
|
interface PromptCardProps {
|
|
7
9
|
prompt: Prompt;
|
|
8
10
|
project: string;
|
|
9
11
|
sessionId: string;
|
|
10
|
-
onMutate: () => void
|
|
12
|
+
onMutate: () => void | Promise<void>;
|
|
11
13
|
onDragStart?: (id: string) => void;
|
|
12
14
|
onDragOver?: (id: string, position: 'before' | 'after') => void;
|
|
13
15
|
onDragEnd?: () => void;
|
|
@@ -54,6 +56,7 @@ export function PromptCard({
|
|
|
54
56
|
const [editing, setEditing] = useState(false);
|
|
55
57
|
const [editText, setEditText] = useState(prompt.text);
|
|
56
58
|
const [saving, setSaving] = useState(false);
|
|
59
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
57
60
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
58
61
|
|
|
59
62
|
const styles = STATUS_STYLES[prompt.status];
|
|
@@ -84,18 +87,20 @@ export function PromptCard({
|
|
|
84
87
|
function handleEditCancel() {
|
|
85
88
|
setEditText(prompt.text);
|
|
86
89
|
setEditing(false);
|
|
90
|
+
setActionError(null);
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
async function handleEditSave() {
|
|
90
94
|
const trimmed = editText.trim();
|
|
91
95
|
if (!trimmed || saving) return;
|
|
92
96
|
setSaving(true);
|
|
97
|
+
setActionError(null);
|
|
93
98
|
try {
|
|
94
99
|
await api.updatePrompt(project, sessionId, prompt.id, { text: trimmed });
|
|
95
100
|
setEditing(false);
|
|
96
|
-
onMutate();
|
|
97
|
-
} catch {
|
|
98
|
-
|
|
101
|
+
await onMutate();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
setActionError(toErrorMessage(error));
|
|
99
104
|
} finally {
|
|
100
105
|
setSaving(false);
|
|
101
106
|
}
|
|
@@ -115,11 +120,12 @@ export function PromptCard({
|
|
|
115
120
|
e.stopPropagation();
|
|
116
121
|
const confirmed = window.confirm('Delete this prompt?');
|
|
117
122
|
if (!confirmed) return;
|
|
123
|
+
setActionError(null);
|
|
118
124
|
try {
|
|
119
125
|
await api.deletePrompt(project, sessionId, prompt.id);
|
|
120
|
-
onMutate();
|
|
121
|
-
} catch {
|
|
122
|
-
|
|
126
|
+
await onMutate();
|
|
127
|
+
} catch (error) {
|
|
128
|
+
setActionError(toErrorMessage(error));
|
|
123
129
|
}
|
|
124
130
|
}
|
|
125
131
|
|
|
@@ -278,6 +284,8 @@ export function PromptCard({
|
|
|
278
284
|
{prompt.text}
|
|
279
285
|
</p>
|
|
280
286
|
)}
|
|
287
|
+
|
|
288
|
+
{actionError && <InlineAlert message={actionError} className="mt-2" />}
|
|
281
289
|
</div>
|
|
282
290
|
</div>
|
|
283
291
|
</div>
|
|
@@ -1,48 +1,36 @@
|
|
|
1
1
|
import { useState, useRef } from 'react';
|
|
2
2
|
import { api } from '../api/client';
|
|
3
|
-
import type { SessionWithStatus,
|
|
3
|
+
import type { SessionWithStatus, Prompt } from '../types/queue';
|
|
4
4
|
import { TrashIcon } from './TrashIcon';
|
|
5
5
|
import { PromptCard } from './PromptCard';
|
|
6
6
|
import { AddPromptForm } from './AddPromptForm';
|
|
7
|
+
import { InlineAlert } from './InlineAlert';
|
|
8
|
+
import { StatusDot } from './StatusDot';
|
|
9
|
+
import { toErrorMessage } from '../utils/errors';
|
|
7
10
|
|
|
8
11
|
interface SessionSectionProps {
|
|
9
12
|
session: SessionWithStatus;
|
|
10
13
|
project: string;
|
|
11
|
-
onMutate: () => void
|
|
14
|
+
onMutate: () => void | Promise<void>;
|
|
12
15
|
defaultExpanded?: boolean;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
function StatusDot({ status }: { status: SessionStatus }) {
|
|
16
|
-
if (status === 'active') {
|
|
17
|
-
return (
|
|
18
|
-
<span
|
|
19
|
-
className="animate-pulse-dot inline-block w-2 h-2 rounded-full bg-[var(--color-active)] shrink-0"
|
|
20
|
-
aria-label="Active session"
|
|
21
|
-
/>
|
|
22
|
-
);
|
|
23
|
-
}
|
|
24
|
-
return (
|
|
25
|
-
<span
|
|
26
|
-
className="inline-block w-2 h-2 rounded-full bg-[var(--color-idle)] shrink-0"
|
|
27
|
-
aria-label="Idle session"
|
|
28
|
-
/>
|
|
29
|
-
);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
18
|
export function SessionSection({ session, project, onMutate, defaultExpanded = true }: SessionSectionProps) {
|
|
33
19
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
34
20
|
const [dragOver, setDragOver] = useState<{ id: string; position: 'before' | 'after' } | null>(null);
|
|
35
21
|
const [draggingId, setDraggingId] = useState<string | null>(null);
|
|
22
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
36
23
|
const dragSourceRef = useRef<string | null>(null);
|
|
37
24
|
|
|
38
25
|
async function handleClearPrompts(e: React.MouseEvent) {
|
|
39
26
|
e.stopPropagation();
|
|
40
27
|
if (!window.confirm('Clear all prompts?')) return;
|
|
28
|
+
setActionError(null);
|
|
41
29
|
try {
|
|
42
30
|
await api.clearPrompts(project, session.sessionId);
|
|
43
|
-
onMutate();
|
|
44
|
-
} catch {
|
|
45
|
-
|
|
31
|
+
await onMutate();
|
|
32
|
+
} catch (error) {
|
|
33
|
+
setActionError(toErrorMessage(error));
|
|
46
34
|
}
|
|
47
35
|
}
|
|
48
36
|
|
|
@@ -71,7 +59,7 @@ export function SessionSection({ session, project, onMutate, defaultExpanded = t
|
|
|
71
59
|
dragSourceRef.current = null;
|
|
72
60
|
}
|
|
73
61
|
|
|
74
|
-
function handleDrop(targetId: string) {
|
|
62
|
+
async function handleDrop(targetId: string) {
|
|
75
63
|
const sourceId = dragSourceRef.current;
|
|
76
64
|
const position = dragOver?.position ?? 'before';
|
|
77
65
|
if (!sourceId || sourceId === targetId) {
|
|
@@ -95,7 +83,13 @@ export function SessionSection({ session, project, onMutate, defaultExpanded = t
|
|
|
95
83
|
|
|
96
84
|
const newOrder = reordered.map(p => p.id);
|
|
97
85
|
handleDragEnd();
|
|
98
|
-
|
|
86
|
+
setActionError(null);
|
|
87
|
+
try {
|
|
88
|
+
await api.reorderPrompts(project, session.sessionId, newOrder);
|
|
89
|
+
await onMutate();
|
|
90
|
+
} catch (error) {
|
|
91
|
+
setActionError(toErrorMessage(error));
|
|
92
|
+
}
|
|
99
93
|
}
|
|
100
94
|
|
|
101
95
|
function renderPromptList(prompts: Prompt[], reorderable: boolean) {
|
|
@@ -168,6 +162,8 @@ export function SessionSection({ session, project, onMutate, defaultExpanded = t
|
|
|
168
162
|
{/* Session content */}
|
|
169
163
|
{expanded && (
|
|
170
164
|
<div className="px-4 pb-4 space-y-2">
|
|
165
|
+
{actionError && <InlineAlert message={actionError} className="pt-2" />}
|
|
166
|
+
|
|
171
167
|
{activePrompts.length === 0 && donePrompts.length === 0 && (
|
|
172
168
|
<p className="text-xs text-[var(--color-muted)] py-2">No prompts yet</p>
|
|
173
169
|
)}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
1
2
|
import type { ProjectView } from '../types/queue';
|
|
3
|
+
import { StatusDot } from './StatusDot';
|
|
2
4
|
|
|
3
5
|
interface SidebarProps {
|
|
4
6
|
projects: ProjectView[];
|
|
@@ -18,32 +20,27 @@ function getPendingCount(project: ProjectView): number {
|
|
|
18
20
|
);
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
function
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
);
|
|
23
|
+
function getProjectRank(project: ProjectView, pendingCount: number): number {
|
|
24
|
+
if (pendingCount > 0) return 0;
|
|
25
|
+
if (getSessionStatus(project) === 'active') return 1;
|
|
26
|
+
return 2;
|
|
44
27
|
}
|
|
45
28
|
|
|
46
29
|
export function Sidebar({ projects, selectedProject, onSelectProject }: SidebarProps) {
|
|
30
|
+
const pendingCounts = useMemo(
|
|
31
|
+
() => new Map(projects.map((project) => [project.project, getPendingCount(project)])),
|
|
32
|
+
[projects],
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const sortedProjects = useMemo(
|
|
36
|
+
() => [...projects].sort((a, b) => {
|
|
37
|
+
const aPending = pendingCounts.get(a.project) ?? 0;
|
|
38
|
+
const bPending = pendingCounts.get(b.project) ?? 0;
|
|
39
|
+
return getProjectRank(a, aPending) - getProjectRank(b, bPending);
|
|
40
|
+
}),
|
|
41
|
+
[pendingCounts, projects],
|
|
42
|
+
);
|
|
43
|
+
|
|
47
44
|
return (
|
|
48
45
|
<aside
|
|
49
46
|
className="flex flex-col w-[280px] shrink-0 h-full bg-[var(--color-surface)] border-r border-[var(--color-border)]"
|
|
@@ -65,16 +62,9 @@ export function Sidebar({ projects, selectedProject, onSelectProject }: SidebarP
|
|
|
65
62
|
<p className="px-5 py-4 text-xs text-[var(--color-muted)]">No projects found.</p>
|
|
66
63
|
)}
|
|
67
64
|
<ul role="list">
|
|
68
|
-
{
|
|
69
|
-
const rank = (p: ProjectView) => {
|
|
70
|
-
if (getPendingCount(p) > 0) return 0;
|
|
71
|
-
if (getSessionStatus(p) === 'active') return 1;
|
|
72
|
-
return 2;
|
|
73
|
-
};
|
|
74
|
-
return rank(a) - rank(b);
|
|
75
|
-
}).map((project) => {
|
|
65
|
+
{sortedProjects.map((project) => {
|
|
76
66
|
const status = getSessionStatus(project);
|
|
77
|
-
const pending =
|
|
67
|
+
const pending = pendingCounts.get(project.project) ?? 0;
|
|
78
68
|
const isSelected = project.project === selectedProject;
|
|
79
69
|
const sessionCount = project.sessions.length;
|
|
80
70
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
interface StatusDotProps {
|
|
2
|
+
status: 'active' | 'idle' | 'none';
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
const STATUS_PROPS: Record<StatusDotProps['status'], { className: string; label: string }> = {
|
|
6
|
+
active: {
|
|
7
|
+
className: 'animate-pulse-dot bg-[var(--color-active)]',
|
|
8
|
+
label: 'Active session',
|
|
9
|
+
},
|
|
10
|
+
idle: {
|
|
11
|
+
className: 'bg-[var(--color-idle)]',
|
|
12
|
+
label: 'Idle session',
|
|
13
|
+
},
|
|
14
|
+
none: {
|
|
15
|
+
className: 'bg-[var(--color-muted)]',
|
|
16
|
+
label: 'No session',
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function StatusDot({ status }: StatusDotProps) {
|
|
21
|
+
const dot = STATUS_PROPS[status];
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<span
|
|
25
|
+
className={['inline-block w-2 h-2 rounded-full shrink-0', dot.className].join(' ')}
|
|
26
|
+
aria-label={dot.label}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
package/src/hooks/useQueues.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
|
|
2
2
|
import type { ProjectView } from '../types/queue';
|
|
3
3
|
import { api } from '../api/client';
|
|
4
4
|
import { useSSE } from './useSSE';
|
|
5
|
+
import { toErrorMessage } from '../utils/errors';
|
|
5
6
|
|
|
6
7
|
const FALLBACK_POLL_MS = 2000;
|
|
7
8
|
|
|
@@ -25,7 +26,7 @@ export function useProjects() {
|
|
|
25
26
|
setProjects(data);
|
|
26
27
|
setError(null);
|
|
27
28
|
} catch (err: unknown) {
|
|
28
|
-
setError(err
|
|
29
|
+
setError(toErrorMessage(err));
|
|
29
30
|
} finally {
|
|
30
31
|
setLoading(false);
|
|
31
32
|
}
|
|
@@ -40,6 +41,7 @@ export function useProjects() {
|
|
|
40
41
|
}
|
|
41
42
|
return;
|
|
42
43
|
}
|
|
44
|
+
void refresh();
|
|
43
45
|
fallbackRef.current = setInterval(refresh, FALLBACK_POLL_MS);
|
|
44
46
|
return () => {
|
|
45
47
|
if (fallbackRef.current) {
|
package/src/hooks/useSSE.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect,
|
|
1
|
+
import { useEffect, useEffectEvent, useState } from 'react';
|
|
2
2
|
import type { ProjectView } from '../types/queue';
|
|
3
3
|
|
|
4
4
|
interface UseSSEOptions {
|
|
@@ -7,16 +7,15 @@ interface UseSSEOptions {
|
|
|
7
7
|
|
|
8
8
|
export function useSSE({ onProjects }: UseSSEOptions) {
|
|
9
9
|
const [connected, setConnected] = useState(false);
|
|
10
|
-
const
|
|
11
|
-
callbackRef.current = onProjects;
|
|
10
|
+
const handleProjects = useEffectEvent(onProjects);
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
useEffect(() => {
|
|
14
13
|
const es = new EventSource('/api/events');
|
|
15
14
|
|
|
16
15
|
es.addEventListener('projects', (event: MessageEvent) => {
|
|
17
16
|
try {
|
|
18
17
|
const data = JSON.parse(event.data) as ProjectView[];
|
|
19
|
-
|
|
18
|
+
handleProjects(data);
|
|
20
19
|
} catch {
|
|
21
20
|
// Ignore malformed events
|
|
22
21
|
}
|
|
@@ -24,14 +23,8 @@ export function useSSE({ onProjects }: UseSSEOptions) {
|
|
|
24
23
|
|
|
25
24
|
es.onopen = () => setConnected(true);
|
|
26
25
|
es.onerror = () => setConnected(false);
|
|
27
|
-
|
|
28
|
-
return es;
|
|
29
|
-
}, []);
|
|
30
|
-
|
|
31
|
-
useEffect(() => {
|
|
32
|
-
const es = connect();
|
|
33
26
|
return () => es.close();
|
|
34
|
-
}, [
|
|
27
|
+
}, []);
|
|
35
28
|
|
|
36
29
|
return { connected };
|
|
37
30
|
}
|