@jxtools/promptline 1.3.19 → 1.3.21

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 CHANGED
@@ -10,6 +10,12 @@ Ever been watching Claude Code work and thought of three more things you need it
10
10
  npm install -g @jxtools/promptline
11
11
  ```
12
12
 
13
+ If your environment requires an explicit npm registry:
14
+
15
+ ```bash
16
+ npm install -g @jxtools/promptline --registry https://registry.npmjs.org/
17
+ ```
18
+
13
19
  ## Usage
14
20
 
15
21
  ```bash
@@ -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,14 +1,79 @@
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
- import { spawn, execSync } from 'child_process'
6
+ import { spawn, execFileSync } 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, '..')
11
12
  const pkg = JSON.parse(readFileSync(resolve(pkgDir, 'package.json'), 'utf-8'))
13
+ const registryFile = resolve(pkgDir, '.npm-registry')
14
+
15
+ function savedRegistry() {
16
+ if (!existsSync(registryFile)) return ''
17
+ return readFileSync(registryFile, 'utf-8').trim()
18
+ }
19
+
20
+ function npmRegistry() {
21
+ const explicit = process.env.npm_config_registry || process.env.NPM_CONFIG_REGISTRY
22
+ if (explicit) return explicit
23
+
24
+ const saved = savedRegistry()
25
+ if (saved) return saved
26
+
27
+ try {
28
+ return execFileSync('npm', ['config', 'get', 'registry'], {
29
+ encoding: 'utf-8',
30
+ stdio: ['ignore', 'pipe', 'ignore'],
31
+ }).trim()
32
+ } catch {
33
+ return ''
34
+ }
35
+ }
36
+
37
+ function versionKey(version) {
38
+ return version
39
+ .replace(/^v/, '')
40
+ .split('.')
41
+ .map(part => part.padStart(6, '0'))
42
+ .join('')
43
+ }
44
+
45
+ function isNewerVersion(candidate, current) {
46
+ return versionKey(candidate) > versionKey(current)
47
+ }
48
+
49
+ function npmViewLatestVersion(registry) {
50
+ const args = ['view', '@jxtools/promptline', 'version']
51
+ if (registry) args.push('--registry', registry)
52
+
53
+ return execFileSync('npm', args, {
54
+ encoding: 'utf-8',
55
+ stdio: ['ignore', 'pipe', 'ignore'],
56
+ env: {
57
+ ...process.env,
58
+ npm_config_fetch_retries: '0',
59
+ npm_config_fetch_timeout: '5000',
60
+ },
61
+ }).trim()
62
+ }
63
+
64
+ function npmInstallLatest(registry) {
65
+ const args = ['install', '-g', '@jxtools/promptline@latest']
66
+ if (registry) args.push('--registry', registry)
67
+
68
+ execFileSync('npm', args, {
69
+ stdio: 'inherit',
70
+ env: {
71
+ ...process.env,
72
+ npm_config_fetch_retries: '0',
73
+ npm_config_fetch_timeout: '10000',
74
+ },
75
+ })
76
+ }
12
77
 
13
78
  // --version
14
79
  if (process.argv.includes('--version') || process.argv.includes('-v')) {
@@ -19,13 +84,14 @@ if (process.argv.includes('--version') || process.argv.includes('-v')) {
19
84
  // update
20
85
  if (process.argv[2] === 'update') {
21
86
  const current = pkg.version
87
+ const registry = npmRegistry()
22
88
  console.log(`\x1b[36m⟳\x1b[0m Current version: v${current}`)
23
89
  console.log(` Checking for updates...`)
24
90
 
25
91
  try {
26
- const latest = execSync('npm view @jxtools/promptline version', { encoding: 'utf-8' }).trim()
92
+ const latest = npmViewLatestVersion(registry)
27
93
 
28
- if (latest === current) {
94
+ if (!isNewerVersion(latest, current)) {
29
95
  console.log(`\x1b[32m✓\x1b[0m Already on the latest version (v${current})`)
30
96
  process.exit(0)
31
97
  }
@@ -33,10 +99,11 @@ if (process.argv[2] === 'update') {
33
99
  console.log(`\x1b[33m↑\x1b[0m New version available: v${latest}`)
34
100
  console.log(` Updating...`)
35
101
 
36
- execSync('npm install -g @jxtools/promptline@latest', { stdio: 'inherit' })
102
+ npmInstallLatest(registry)
37
103
  console.log(`\n\x1b[32m✓\x1b[0m Updated to v${latest}`)
38
104
  } catch (err) {
39
- console.error(`\x1b[31m✗\x1b[0m Update failed: ${err.message}`)
105
+ const suffix = registry ? ` (registry: ${registry})` : ''
106
+ console.error(`\x1b[31m✗\x1b[0m Update failed${suffix}: ${toErrorMessage(err)}`)
40
107
  process.exit(1)
41
108
  }
42
109
 
@@ -150,57 +217,15 @@ function cancelAllPendingPrompts() {
150
217
  // --- Helpers ---
151
218
 
152
219
  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
- }
220
+ try {
221
+ installPromptlineHooks({
222
+ claudeDir,
223
+ hooksDir,
224
+ pkgDir,
225
+ hookFiles,
226
+ })
227
+ } catch (error) {
228
+ console.error(`\x1b[31m✗\x1b[0m ${toErrorMessage(error)}`)
229
+ process.exit(1)
203
230
  }
204
-
205
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n')
206
231
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxtools/promptline",
3
- "version": "1.3.19",
3
+ "version": "1.3.21",
4
4
  "type": "module",
5
5
  "license": "ISC",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "bin/",
11
+ "scripts/",
11
12
  "src/",
12
13
  "promptline-*.sh",
13
14
  "vite-plugin-api.ts",
@@ -26,34 +27,34 @@
26
27
  "access": "public"
27
28
  },
28
29
  "scripts": {
30
+ "postinstall": "node scripts/postinstall.js",
29
31
  "dev": "vite",
30
32
  "build": "tsc -b && vite build",
31
33
  "lint": "eslint .",
32
- "preview": "vite preview",
33
- "test": "vitest run",
34
- "test:watch": "vitest"
34
+ "preview": "vite preview"
35
35
  },
36
36
  "dependencies": {
37
- "@tailwindcss/vite": "^4.2.1",
38
- "@vitejs/plugin-react": "^5.1.1",
39
37
  "react": "^19.2.0",
40
38
  "react-dom": "^19.2.0",
41
- "tailwindcss": "^4.2.1",
42
- "typescript": "~5.9.3",
43
39
  "uuid": "^13.0.0",
44
40
  "vite": "^7.3.1"
45
41
  },
46
42
  "devDependencies": {
43
+ "@tailwindcss/vite": "^4.2.1",
47
44
  "@eslint/js": "^9.39.1",
48
45
  "@types/node": "^24.10.1",
49
46
  "@types/react": "^19.2.7",
50
47
  "@types/react-dom": "^19.2.3",
51
- "@types/uuid": "^10.0.0",
48
+ "@vitejs/plugin-react": "^5.1.1",
52
49
  "eslint": "^9.39.1",
53
50
  "eslint-plugin-react-hooks": "^7.0.1",
54
51
  "eslint-plugin-react-refresh": "^0.4.24",
55
52
  "globals": "^16.5.0",
56
- "typescript-eslint": "^8.48.0",
57
- "vitest": "^4.0.18"
53
+ "tailwindcss": "^4.2.1",
54
+ "typescript": "~5.9.3",
55
+ "typescript-eslint": "^8.48.0"
56
+ },
57
+ "overrides": {
58
+ "flatted": "^3.4.2"
58
59
  }
59
60
  }
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync } from 'child_process'
4
+ import { writeFileSync } from 'fs'
5
+ import { dirname, resolve } from 'path'
6
+ import { fileURLToPath } from 'url'
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url))
9
+ const pkgDir = resolve(__dirname, '..')
10
+ const registryFile = resolve(pkgDir, '.npm-registry')
11
+
12
+ function resolveRegistry(env) {
13
+ const explicit = env.npm_config_registry || env.NPM_CONFIG_REGISTRY
14
+ if (explicit) return explicit
15
+
16
+ try {
17
+ return execFileSync('npm', ['config', 'get', 'registry'], {
18
+ encoding: 'utf-8',
19
+ stdio: ['ignore', 'pipe', 'ignore'],
20
+ }).trim()
21
+ } catch {
22
+ return ''
23
+ }
24
+ }
25
+
26
+ const registry = resolveRegistry(process.env)
27
+ if (registry) {
28
+ writeFileSync(registryFile, `${registry}\n`)
29
+ }
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
+ }