@jsonresume/jobs 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui/App.js CHANGED
@@ -24,9 +24,19 @@ import StatusBar from './StatusBar.js';
24
24
  import AIPanel from './AIPanel.js';
25
25
  import HelpModal from './HelpModal.js';
26
26
 
27
- const TABS = ['all', 'interested', 'applied', 'maybe', 'passed'];
27
+ const TABS = [
28
+ 'all',
29
+ 'new',
30
+ 'reviewed',
31
+ 'interested',
32
+ 'applied',
33
+ 'maybe',
34
+ 'passed',
35
+ ];
28
36
  const TAB_LABELS = {
29
37
  all: 'All',
38
+ new: 'New',
39
+ reviewed: 'Reviewed',
30
40
  interested: 'Interested',
31
41
  applied: 'Applied',
32
42
  maybe: 'Maybe',
@@ -65,7 +75,9 @@ function App({ baseUrl, apiKey, apiClient }) {
65
75
  // Active search profile
66
76
  const [activeSearchId, setActiveSearchId] = useState(null);
67
77
 
68
- // Persistent filters
78
+ const searchesHook = useSearches(api);
79
+
80
+ // Persistent filters (local + server sync for search profiles)
69
81
  const [filterStore, setFilterStore] = useState(() => loadFilters());
70
82
  const activeFilters = useMemo(
71
83
  () => getFiltersForSearch(filterStore, activeSearchId),
@@ -78,8 +90,12 @@ function App({ baseUrl, apiKey, apiClient }) {
78
90
  saveFilters(next);
79
91
  return next;
80
92
  });
93
+ // Sync to server for search profiles
94
+ if (activeSearchId) {
95
+ searchesHook.updateFilters(activeSearchId, newActive);
96
+ }
81
97
  },
82
- [activeSearchId]
98
+ [activeSearchId, searchesHook]
83
99
  );
84
100
  const filterState = useMemo(
85
101
  () => ({ active: activeFilters }),
@@ -90,6 +106,22 @@ function App({ baseUrl, apiKey, apiClient }) {
90
106
  [updateFilters]
91
107
  );
92
108
 
109
+ // Sync server-side filters into local store when search profiles load
110
+ useEffect(() => {
111
+ if (!searchesHook.searches.length) return;
112
+ setFilterStore((prev) => {
113
+ let updated = prev;
114
+ for (const s of searchesHook.searches) {
115
+ if (s.filters?.length && !prev.searches?.[s.id]) {
116
+ updated = setFiltersForSearch(updated, s.id, s.filters);
117
+ }
118
+ }
119
+ if (updated !== prev) saveFilters(updated);
120
+ return updated;
121
+ });
122
+ }, [searchesHook.searches]);
123
+
124
+ const ai = useAI(resume);
93
125
  const {
94
126
  jobs: rawJobs,
95
127
  allJobs,
@@ -98,10 +130,25 @@ function App({ baseUrl, apiKey, apiClient }) {
98
130
  error,
99
131
  markJob,
100
132
  forceRefresh,
101
- } = useJobs(api, activeFilters, tab, activeSearchId);
102
- const ai = useAI(resume);
103
- const searchesHook = useSearches(api);
133
+ } = useJobs(api, activeFilters, tab, activeSearchId, ai.getDossierStatus);
104
134
  const { toast, show: showToast } = useToast();
135
+ const [confirmExit, setConfirmExit] = useState(false);
136
+
137
+ // Seed dossier icons from server-side flags when jobs load
138
+ useEffect(() => {
139
+ if (allJobs.length) ai.seedDossierFlags(allJobs);
140
+ }, [allJobs, ai]);
141
+
142
+ // Kill claude processes on exit (Ctrl+C)
143
+ useEffect(() => {
144
+ const cleanup = () => ai.cancel();
145
+ process.on('SIGINT', cleanup);
146
+ process.on('SIGTERM', cleanup);
147
+ return () => {
148
+ process.removeListener('SIGINT', cleanup);
149
+ process.removeListener('SIGTERM', cleanup);
150
+ };
151
+ }, [ai]);
105
152
 
106
153
  // Apply inline search filter
107
154
  const jobs = useMemo(() => {
@@ -128,12 +175,12 @@ function App({ baseUrl, apiKey, apiClient }) {
128
175
  .catch(() => {});
129
176
  }, [api]);
130
177
 
131
- // Update selectedJob when cursor moves in detail view
178
+ // Update selectedJob when cursor moves or jobs list changes in detail view
132
179
  useEffect(() => {
133
180
  if (view === 'detail' && jobs[cursor]) {
134
181
  setSelectedJob(jobs[cursor]);
135
182
  }
136
- }, [cursor, view]);
183
+ }, [cursor, view, jobs]);
137
184
 
138
185
  // Inline search escape handler
139
186
  useInput(
@@ -153,7 +200,19 @@ function App({ baseUrl, apiKey, apiClient }) {
153
200
  if (view === 'filters' || view === 'searches' || view === 'help') return;
154
201
  if (inlineSearch) return;
155
202
 
156
- if (input === 'q' && view === 'list') exit();
203
+ if (input === 'q' && view === 'list') {
204
+ if (ai.hasActiveProcess && !confirmExit) {
205
+ showToast(
206
+ 'Claude dossier still running — press q again to quit',
207
+ 'warning'
208
+ );
209
+ setConfirmExit(true);
210
+ return;
211
+ }
212
+ ai.cancel();
213
+ exit();
214
+ }
215
+ if (input !== 'q') setConfirmExit(false);
157
216
  if (input === 'q' && view === 'detail') setView('list');
158
217
  if (input === 'R' && (view === 'list' || view === 'detail')) {
159
218
  forceRefresh();
@@ -176,9 +235,12 @@ function App({ baseUrl, apiKey, apiClient }) {
176
235
  setView('detail');
177
236
  }
178
237
  if (key.escape && view === 'detail') setView('list');
238
+ if (input === 'c' && view === 'detail' && selectedJob) {
239
+ handleDossier(selectedJob);
240
+ }
179
241
 
180
242
  if (key.escape && view === 'ai') {
181
- ai.clear();
243
+ // Don't kill running dossier — just hide the panel
182
244
  setView(selectedJob ? 'detail' : 'list');
183
245
  }
184
246
 
@@ -219,6 +281,11 @@ function App({ baseUrl, apiKey, apiClient }) {
219
281
  setView('ai');
220
282
  ai.summarizeJob(job);
221
283
  };
284
+ const handleDossier = (job) => {
285
+ setSelectedJob(job);
286
+ setView('ai');
287
+ ai.dossier(job, api);
288
+ };
222
289
  const handleAIBatch = (visibleJobs) => {
223
290
  setView('ai');
224
291
  ai.batchReview(visibleJobs);
@@ -252,6 +319,16 @@ function App({ baseUrl, apiKey, apiClient }) {
252
319
 
253
320
  const counts = {
254
321
  all: allJobs.length,
322
+ new: allJobs.filter(
323
+ (j) =>
324
+ !j.state &&
325
+ !j.has_dossier &&
326
+ ai.getDossierStatus(j.id) !== 'done' &&
327
+ ai.getDossierStatus(j.id) !== 'generating'
328
+ ).length,
329
+ reviewed: allJobs.filter(
330
+ (j) => (j.has_dossier || ai.getDossierStatus(j.id) === 'done') && !j.state
331
+ ).length,
255
332
  interested: allJobs.filter((j) => j.state === 'interested').length,
256
333
  applied: allJobs.filter((j) => j.state === 'applied').length,
257
334
  maybe: allJobs.filter((j) => j.state === 'maybe').length,
@@ -312,6 +389,9 @@ function App({ baseUrl, apiKey, apiClient }) {
312
389
  onCursorChange: setCursor,
313
390
  onSelect: handleSelect,
314
391
  onMark: handleMark,
392
+ onAISummary: handleAISummary,
393
+ onDossier: handleDossier,
394
+ getDossierStatus: ai.getDossierStatus,
315
395
  isActive: true,
316
396
  compact: true,
317
397
  reservedRows: 8,
@@ -327,6 +407,8 @@ function App({ baseUrl, apiKey, apiClient }) {
327
407
  onBack: handleBack,
328
408
  onMark: handleMark,
329
409
  onAISummary: handleAISummary,
410
+ onDossier: handleDossier,
411
+ getDossierStatus: ai.getDossierStatus,
330
412
  isActive: false,
331
413
  isPanel: true,
332
414
  })
@@ -336,6 +418,74 @@ function App({ baseUrl, apiKey, apiClient }) {
336
418
  );
337
419
  }
338
420
 
421
+ // Split-pane: compact list on left, AI/dossier on right
422
+ if (view === 'ai' && selectedJob) {
423
+ return h(
424
+ Box,
425
+ { flexDirection: 'column', height: process.stdout.rows || 40 },
426
+ header,
427
+ h(
428
+ Box,
429
+ { flexGrow: 1, flexDirection: 'row' },
430
+ // Left pane: compact job list
431
+ h(
432
+ Box,
433
+ {
434
+ flexDirection: 'column',
435
+ width: '40%',
436
+ borderStyle: 'single',
437
+ borderColor: 'gray',
438
+ borderRight: true,
439
+ borderLeft: false,
440
+ borderTop: false,
441
+ borderBottom: false,
442
+ },
443
+ h(JobList, {
444
+ jobs,
445
+ cursor,
446
+ tab,
447
+ onCursorChange: setCursor,
448
+ onSelect: handleSelect,
449
+ onMark: handleMark,
450
+ onAISummary: handleAISummary,
451
+ onDossier: handleDossier,
452
+ getDossierStatus: ai.getDossierStatus,
453
+ isActive: false,
454
+ compact: true,
455
+ reservedRows: 8,
456
+ })
457
+ ),
458
+ // Right pane: AI/dossier panel
459
+ h(
460
+ Box,
461
+ { flexDirection: 'column', width: '60%' },
462
+ h(AIPanel, {
463
+ text: ai.text,
464
+ loading: ai.loading,
465
+ error: ai.error,
466
+ mode: ai.mode,
467
+ job: selectedJob,
468
+ onMark: handleMark,
469
+ onDismiss: () => {
470
+ setView(selectedJob ? 'detail' : 'list');
471
+ },
472
+ onExport: () => {
473
+ const f = ai.exportDossier(selectedJob);
474
+ if (f) showToast(`Saved ./${f}`, 'export');
475
+ return f;
476
+ },
477
+ onRegenerate: (job) => {
478
+ ai.regenerateDossier(job, api);
479
+ showToast('Regenerating dossier…', 'info');
480
+ },
481
+ isActive: true,
482
+ })
483
+ )
484
+ ),
485
+ statusBar
486
+ );
487
+ }
488
+
339
489
  // Full-width list view
340
490
  return h(
341
491
  Box,
@@ -350,6 +500,7 @@ function App({ baseUrl, apiKey, apiClient }) {
350
500
  onSelect: handleSelect,
351
501
  onMark: handleMark,
352
502
  onAISummary: handleAISummary,
503
+ onDossier: handleDossier,
353
504
  onAIBatch: handleAIBatch,
354
505
  onExport: handleExport,
355
506
  isActive: !inlineSearch,
@@ -384,14 +535,15 @@ function App({ baseUrl, apiKey, apiClient }) {
384
535
  onClose: () => setView('list'),
385
536
  })
386
537
  : null,
387
- view === 'ai'
538
+ view === 'ai' && !selectedJob
388
539
  ? h(AIPanel, {
389
540
  text: ai.text,
390
541
  loading: ai.loading,
391
542
  error: ai.error,
543
+ mode: ai.mode,
392
544
  onDismiss: () => {
393
545
  ai.clear();
394
- setView(selectedJob ? 'detail' : 'list');
546
+ setView('list');
395
547
  },
396
548
  isActive: true,
397
549
  })
@@ -46,6 +46,7 @@ const SECTIONS = [
46
46
  title: 'AI Features (requires OPENAI_API_KEY)',
47
47
  keys: [
48
48
  ['Space', 'AI summary of current job'],
49
+ ['c', 'Research dossier (uses Claude Code CLI)'],
49
50
  ['S', 'AI batch review of visible jobs'],
50
51
  ],
51
52
  },
@@ -10,6 +10,7 @@ export default function JobDetail({
10
10
  onBack,
11
11
  onMark,
12
12
  onAISummary,
13
+ onDossier,
13
14
  isActive,
14
15
  isPanel,
15
16
  }) {
@@ -45,6 +46,7 @@ export default function JobDetail({
45
46
  if (input === 'm') onMark(job.id, 'maybe');
46
47
  if (input === 'p') onMark(job.id, 'not_interested');
47
48
  if (input === ' ') onAISummary(job);
49
+ if (input === 'c' && onDossier) onDossier(job);
48
50
  if (input === 'o' && (detail?.url || job.url)) {
49
51
  import('child_process').then(({ exec }) => {
50
52
  const url = detail?.url || job.url;
@@ -16,13 +16,26 @@ function useColumns(hasRerank, compact) {
16
16
  const cols = stdout?.columns || 120;
17
17
  const available = compact ? Math.floor(cols * 0.4) : cols;
18
18
 
19
+ const dossierW = 2;
20
+
19
21
  if (compact) {
20
- // Compact mode: just score, title, status
22
+ // Compact mode: just score, title, dossier, status
21
23
  const scoreW = 5;
22
24
  const statusW = 2;
23
25
  const gaps = GAP * 2;
24
- const titleW = Math.max(10, available - scoreW - statusW - gaps - 2);
25
- return { cols: available, titleW, compW: 0, locW: 0, scoreW, statusW };
26
+ const titleW = Math.max(
27
+ 10,
28
+ available - scoreW - dossierW - statusW - gaps - 2
29
+ );
30
+ return {
31
+ cols: available,
32
+ titleW,
33
+ compW: 0,
34
+ locW: 0,
35
+ scoreW,
36
+ statusW,
37
+ dossierW,
38
+ };
26
39
  }
27
40
 
28
41
  const scoreW = 5;
@@ -30,8 +43,9 @@ function useColumns(hasRerank, compact) {
30
43
  const salaryW = 12;
31
44
  const statusW = 2;
32
45
  const cursorW = 2;
33
- const gaps = GAP * (hasRerank ? 6 : 5);
34
- const fixed = cursorW + scoreW + aiW + salaryW + statusW + gaps + 2; // +2 paddingX
46
+ const gaps = GAP * (hasRerank ? 7 : 6);
47
+ const fixed =
48
+ cursorW + scoreW + aiW + salaryW + dossierW + statusW + gaps + 2;
35
49
  const flex = Math.max(30, available - fixed);
36
50
  const titleW = Math.max(12, Math.floor(flex * 0.35));
37
51
  const compW = Math.max(10, Math.floor(flex * 0.3));
@@ -44,6 +58,7 @@ function useColumns(hasRerank, compact) {
44
58
  scoreW,
45
59
  salaryW,
46
60
  statusW,
61
+ dossierW,
47
62
  aiW,
48
63
  };
49
64
  }
@@ -99,6 +114,11 @@ function HeaderRow({ hasRerank, titleW, compW, locW, compact }) {
99
114
  { width: 12, marginRight: GAP },
100
115
  h(Text, { bold: true, dimColor: true }, 'Salary')
101
116
  ),
117
+ h(
118
+ Box,
119
+ { width: 2, marginRight: GAP },
120
+ h(Text, { bold: true, dimColor: true }, '📋')
121
+ ),
102
122
  h(Box, { width: 2 }, h(Text, { bold: true, dimColor: true }, ' '))
103
123
  );
104
124
  }
@@ -112,12 +132,15 @@ function JobRow({
112
132
  locW,
113
133
  marked,
114
134
  compact,
135
+ dossierStatus,
115
136
  }) {
116
137
  const loc = formatLocation(job.location, job.remote);
117
138
  const sal = formatSalary(job.salary, job.salary_usd);
118
139
  const score =
119
140
  typeof job.similarity === 'number' ? job.similarity.toFixed(2) : '—';
120
141
  const icon = stateIcon(job.state);
142
+ const dossierIcon =
143
+ dossierStatus === 'generating' ? '◌' : dossierStatus === 'done' ? '📋' : '';
121
144
 
122
145
  const stColor =
123
146
  job.state === 'interested'
@@ -145,6 +168,8 @@ function JobRow({
145
168
  backgroundColor: bg,
146
169
  };
147
170
 
171
+ const dossierColor = dossierStatus === 'generating' ? 'yellow' : 'green';
172
+
148
173
  if (compact) {
149
174
  return h(
150
175
  Box,
@@ -160,6 +185,15 @@ function JobRow({
160
185
  { flexGrow: 1 },
161
186
  h(Text, props, truncate(job.title || '—', titleW))
162
187
  ),
188
+ h(
189
+ Box,
190
+ { width: 2 },
191
+ h(
192
+ Text,
193
+ { ...props, color: dossierIcon ? dossierColor : undefined },
194
+ dossierIcon || ' '
195
+ )
196
+ ),
163
197
  h(Box, { width: 2 }, h(Text, props, icon))
164
198
  );
165
199
  }
@@ -196,6 +230,15 @@ function JobRow({
196
230
  h(Text, props, truncate(loc, locW - 1))
197
231
  ),
198
232
  h(Box, { width: 12, marginRight: GAP }, h(Text, props, truncate(sal, 11))),
233
+ h(
234
+ Box,
235
+ { width: 2, marginRight: GAP },
236
+ h(
237
+ Text,
238
+ { ...props, color: dossierIcon ? dossierColor : undefined },
239
+ dossierIcon || ' '
240
+ )
241
+ ),
199
242
  h(Box, { width: 2 }, h(Text, props, icon))
200
243
  );
201
244
  }
@@ -248,8 +291,10 @@ export default function JobList({
248
291
  onSelect,
249
292
  onMark,
250
293
  onAISummary,
294
+ onDossier,
251
295
  onAIBatch,
252
296
  onExport,
297
+ getDossierStatus,
253
298
  isActive,
254
299
  tab,
255
300
  compact,
@@ -321,6 +366,7 @@ export default function JobList({
321
366
  if (input === 'p' && jobs[cursor])
322
367
  onMark(jobs[cursor].id, 'not_interested');
323
368
  if (input === ' ' && jobs[cursor]) onAISummary(jobs[cursor]);
369
+ if (input === 'c' && jobs[cursor] && onDossier) onDossier(jobs[cursor]);
324
370
  if (input === 'S' && onAIBatch) onAIBatch(jobs);
325
371
  if (input === 'e' && onExport) onExport();
326
372
  },
@@ -344,6 +390,7 @@ export default function JobList({
344
390
  compW,
345
391
  locW,
346
392
  compact,
393
+ dossierStatus: getDossierStatus ? getDossierStatus(job.id) : null,
347
394
  })
348
395
  );
349
396
 
@@ -12,6 +12,7 @@ const KEYS = {
12
12
  ['v', 'select'],
13
13
  ['n', 'find'],
14
14
  ['space', 'AI'],
15
+ ['c', 'dossier'],
15
16
  ],
16
17
  detail: [
17
18
  ['j/k', 'nav jobs'],
@@ -19,6 +20,7 @@ const KEYS = {
19
20
  ['i/x/m/p', 'mark'],
20
21
  ['o', 'open URL'],
21
22
  ['space', 'AI'],
23
+ ['c', 'dossier'],
22
24
  ['esc', 'back'],
23
25
  ],
24
26
  search: [
@@ -39,7 +41,13 @@ const KEYS = {
39
41
  ['d', 'delete'],
40
42
  ['esc', 'close'],
41
43
  ],
42
- ai: [['esc', 'dismiss']],
44
+ ai: [
45
+ ['j/k', 'scroll'],
46
+ ['g/G', 'top/bottom'],
47
+ ['i/x/m/p', 'mark'],
48
+ ['e', 'export'],
49
+ ['esc', 'back'],
50
+ ],
43
51
  help: [['?/esc', 'close']],
44
52
  };
45
53