@jxtools/promptline 1.3.19 → 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.
@@ -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
+ }
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, readFileSync, writeFileSync, copyFileSync, chmodSync, readdirSync, mkdirSync, renameSync, unlinkSync } from 'fs'
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.message}`)
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
- // Copy hook scripts
154
- execSync(`mkdir -p "${hooksDir}"`)
155
-
156
- for (const file of hookFiles) {
157
- const src = join(pkgDir, file)
158
- const dest = join(hooksDir, file)
159
- copyFileSync(src, dest)
160
- chmodSync(dest, 0o755)
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.19",
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
- "@types/uuid": "^10.0.0",
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
- "typescript-eslint": "^8.48.0",
57
- "vitest": "^4.0.18"
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
  }
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
- {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
+ {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
- {!loading && error && (
49
+ {showBlockingLoading && (
34
50
  <div className="flex items-center justify-center h-full">
35
- <p className="text-sm text-red-400">Error: {error}</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
- {!loading && !error && !selectedProject && (
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
- {!loading && !error && selectedProject && (
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) throw new Error(`API error: ${res.status}`);
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
- const end = Date.now() + 10;
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 */ }
@@ -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
- // Keep form open so user can retry
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, SessionWithStatus } from '../types/queue';
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 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) {
16
+ export function ProjectDetail({ project, projects, onProjectDeleted, onMutate }: ProjectDetailProps) {
19
17
  const projectView = selectProject(project, projects);
20
- const [historyOpen, setHistoryOpen] = useState(false);
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
- } catch {
31
- // Silent fail
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
- {visibleSessions.length === 0 && historySessions.length === 0 && (
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
- {visibleSessions.map(session => (
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
- // Keep edit open on error
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
- // Silent fail
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, SessionStatus, Prompt } from '../types/queue';
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
- // Silent fail
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
- api.reorderPrompts(project, session.sessionId, newOrder).then(onMutate).catch(() => {});
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 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
- );
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
- {[...projects].sort((a, b) => {
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 = getPendingCount(project);
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
+ }
@@ -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 instanceof Error ? err.message : 'Unknown error');
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) {
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState, useCallback } from 'react';
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 callbackRef = useRef(onProjects);
11
- callbackRef.current = onProjects;
10
+ const handleProjects = useEffectEvent(onProjects);
12
11
 
13
- const connect = useCallback(() => {
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
- callbackRef.current(data);
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
- }, [connect]);
27
+ }, []);
35
28
 
36
29
  return { connected };
37
30
  }
@@ -0,0 +1,3 @@
1
+ export function toErrorMessage(error: unknown, fallback = 'Something went wrong'): string {
2
+ return error instanceof Error && error.message ? error.message : fallback;
3
+ }