@jsonresume/jobs 0.14.1 → 0.14.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2014-2026 JSON Resume contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/bin/cli.js CHANGED
@@ -120,7 +120,7 @@ async function cmdSearch() {
120
120
  const params = new URLSearchParams();
121
121
  const topArg = parseInt(getArg('--top')) || 20;
122
122
  params.set('top', String(Math.min(Math.max(1, topArg), 100)));
123
- params.set('days', String(parseInt(getArg('--days')) || 30));
123
+ params.set('days', String(parseInt(getArg('--days')) || 90));
124
124
  if (hasFlag('--remote')) params.set('remote', 'true');
125
125
  const minSalary = parseInt(getArg('--min-salary'));
126
126
  if (minSalary > 0) params.set('min_salary', String(minSalary));
@@ -340,7 +340,7 @@ COMMANDS
340
340
 
341
341
  SEARCH OPTIONS
342
342
  --top N Number of results (default: 20, max: 100)
343
- --days N How far back to look (default: 30)
343
+ --days N How far back to look (default: 90)
344
344
  --remote Remote jobs only
345
345
  --min-salary N Minimum salary in thousands (e.g. 150)
346
346
  --search TERM Keyword filter (searches title, company, skills)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsonresume/jobs",
3
- "version": "0.14.1",
3
+ "version": "0.14.2",
4
4
  "type": "module",
5
5
  "description": "Search Hacker News jobs matched against your JSON Resume",
6
6
  "bin": {
@@ -43,5 +43,12 @@
43
43
  "ink-spinner": "^5.0.0",
44
44
  "ink-text-input": "^6.0.0",
45
45
  "react": "^19.2.4"
46
+ },
47
+ "devDependencies": {
48
+ "vitest": "^3.2.6"
49
+ },
50
+ "scripts": {
51
+ "test": "vitest run",
52
+ "test:watch": "vitest"
46
53
  }
47
- }
54
+ }
package/src/formatters.js CHANGED
@@ -4,10 +4,62 @@ export function formatSalary(salary, salaryUsd) {
4
4
  return '—';
5
5
  }
6
6
 
7
+ /**
8
+ * Defensive location normalizer for job records.
9
+ *
10
+ * The registry API serves `job.location` straight from the parsed
11
+ * `gpt_content`, whose canonical shape is an OBJECT
12
+ * (`{ address, postalCode, city, region, countryCode }`, see the
13
+ * extraction schema in apps/registry/scripts/jobs/job-parser/jobSchema.js).
14
+ * Remote status is a SEPARATE field (`job.remote` → 'Full' | 'Hybrid' | 'None').
15
+ *
16
+ * Historic rows may still carry `location` as a plain string, so this
17
+ * normalizer accepts strings, objects, or nullish input and always returns a
18
+ * stable shape:
19
+ * { city, region, countryCode, display, remote }
20
+ * where `remote` is a boolean derived from either the separate `remote`
21
+ * field or a "remote" hint inside a string location.
22
+ *
23
+ * @param {Object|string|null} job - a job record, or a bare location value.
24
+ * @returns {{city:string|null, region:string|null, countryCode:string|null, display:string|null, remote:boolean}}
25
+ */
26
+ export function normalizeLocation(job) {
27
+ // Allow passing either a full job record or a bare location value.
28
+ const isJobRecord = job && typeof job === 'object' && 'location' in job;
29
+ const loc = isJobRecord ? job.location : job;
30
+ const remoteField = isJobRecord ? job.remote : undefined;
31
+
32
+ let city = null;
33
+ let region = null;
34
+ let countryCode = null;
35
+ let display = null;
36
+ let stringRemote = false;
37
+
38
+ if (typeof loc === 'string') {
39
+ const trimmed = loc.trim();
40
+ display = trimmed || null;
41
+ stringRemote = /remote/i.test(trimmed);
42
+ } else if (loc && typeof loc === 'object') {
43
+ city = loc.city || null;
44
+ region = loc.region || null;
45
+ countryCode = loc.countryCode || null;
46
+ const parts = [city, region, countryCode].filter(Boolean);
47
+ display = parts.length ? parts.join(', ') : null;
48
+ }
49
+
50
+ // Mirror the server-side remote filter (matchingHelpers.js), which treats
51
+ // only fully-remote roles as "remote" (j.remote === 'Full'). For historic
52
+ // string locations with no separate `remote` field, fall back to the
53
+ // "remote" hint in the location text.
54
+ const remote = remoteField === 'Full' || stringRemote;
55
+
56
+ return { city, region, countryCode, display, remote };
57
+ }
58
+
7
59
  export function formatLocation(loc, remote) {
60
+ const norm = normalizeLocation(loc);
8
61
  const parts = [];
9
- if (loc?.city) parts.push(loc.city);
10
- if (loc?.countryCode) parts.push(loc.countryCode);
62
+ if (norm.display) parts.push(norm.display);
11
63
  if (remote) parts.push(`(${remote})`);
12
64
  return parts.join(', ') || '—';
13
65
  }
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { normalizeLocation, formatLocation } from './formatters.js';
3
+
4
+ describe('normalizeLocation', () => {
5
+ it('normalizes the canonical object location shape', () => {
6
+ const job = {
7
+ location: { city: 'Berlin', region: 'BE', countryCode: 'DE' },
8
+ remote: 'None',
9
+ };
10
+ const norm = normalizeLocation(job);
11
+ expect(norm).toEqual({
12
+ city: 'Berlin',
13
+ region: 'BE',
14
+ countryCode: 'DE',
15
+ display: 'Berlin, BE, DE',
16
+ remote: false,
17
+ });
18
+ });
19
+
20
+ it('builds display from whichever object fields are present', () => {
21
+ expect(normalizeLocation({ location: { city: 'Seattle' } }).display).toBe(
22
+ 'Seattle'
23
+ );
24
+ expect(normalizeLocation({ location: { countryCode: 'US' } }).display).toBe(
25
+ 'US'
26
+ );
27
+ expect(normalizeLocation({ location: {} }).display).toBeNull();
28
+ });
29
+
30
+ it('handles a historic plain-string location', () => {
31
+ const norm = normalizeLocation({ location: 'San Francisco, CA' });
32
+ expect(norm.display).toBe('San Francisco, CA');
33
+ expect(norm.city).toBeNull();
34
+ expect(norm.remote).toBe(false);
35
+ });
36
+
37
+ it('detects "remote" inside a string location (historic rows)', () => {
38
+ expect(normalizeLocation({ location: 'Remote (US)' }).remote).toBe(true);
39
+ expect(normalizeLocation({ location: 'Fully remote' }).remote).toBe(true);
40
+ expect(normalizeLocation({ location: 'remote' }).remote).toBe(true);
41
+ });
42
+
43
+ it('uses the separate remote field for object locations', () => {
44
+ expect(
45
+ normalizeLocation({ location: { city: 'NYC' }, remote: 'Full' }).remote
46
+ ).toBe(true);
47
+ expect(
48
+ normalizeLocation({ location: { city: 'NYC' }, remote: 'Hybrid' }).remote
49
+ ).toBe(false);
50
+ expect(
51
+ normalizeLocation({ location: { city: 'NYC' }, remote: 'None' }).remote
52
+ ).toBe(false);
53
+ });
54
+
55
+ it('does NOT match remote on an object location (regression guard)', () => {
56
+ // The old `/remote/i.test(j.location || '')` stringified objects to
57
+ // "[object Object]" and never matched — but an object whose city happened
58
+ // to contain "remote" should not flip the flag either.
59
+ const job = { location: { city: 'Remoteville' }, remote: 'None' };
60
+ expect(normalizeLocation(job).remote).toBe(false);
61
+ });
62
+
63
+ it('handles null / undefined / empty location', () => {
64
+ expect(normalizeLocation({ location: null })).toEqual({
65
+ city: null,
66
+ region: null,
67
+ countryCode: null,
68
+ display: null,
69
+ remote: false,
70
+ });
71
+ expect(normalizeLocation({ location: undefined }).display).toBeNull();
72
+ expect(normalizeLocation({ location: '' }).display).toBeNull();
73
+ expect(normalizeLocation(null).display).toBeNull();
74
+ expect(normalizeLocation(undefined).remote).toBe(false);
75
+ });
76
+
77
+ it('accepts a bare location value (not wrapped in a job)', () => {
78
+ expect(
79
+ normalizeLocation({ city: 'Austin', countryCode: 'US' }).display
80
+ ).toBe('Austin, US');
81
+ expect(normalizeLocation('Remote').remote).toBe(true);
82
+ });
83
+
84
+ it('trims whitespace from string locations', () => {
85
+ expect(normalizeLocation({ location: ' London ' }).display).toBe(
86
+ 'London'
87
+ );
88
+ expect(normalizeLocation({ location: ' ' }).display).toBeNull();
89
+ });
90
+ });
91
+
92
+ describe('formatLocation', () => {
93
+ it('formats object locations with an explicit remote suffix', () => {
94
+ expect(formatLocation({ city: 'Berlin', countryCode: 'DE' }, 'Full')).toBe(
95
+ 'Berlin, DE, (Full)'
96
+ );
97
+ });
98
+
99
+ it('formats string locations', () => {
100
+ expect(formatLocation('San Francisco, CA')).toBe('San Francisco, CA');
101
+ });
102
+
103
+ it('falls back to an em dash when empty', () => {
104
+ expect(formatLocation(null)).toBe('—');
105
+ expect(formatLocation({})).toBe('—');
106
+ });
107
+ });
package/src/tui/App.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import React, { useState, useEffect, useMemo, useCallback } from 'react';
2
- import { render, Box, Text, useInput, useApp } from 'ink';
3
- import TextInput from 'ink-text-input';
2
+ import { render, Box, useInput, useApp } from 'ink';
4
3
  import { h } from './h.js';
5
4
  import { createApiClient } from '../api.js';
6
5
  import {
@@ -23,35 +22,15 @@ import SearchManager from './SearchManager.js';
23
22
  import StatusBar from './StatusBar.js';
24
23
  import AIPanel from './AIPanel.js';
25
24
  import HelpModal from './HelpModal.js';
26
-
27
- const TABS = [
28
- 'all',
29
- 'new',
30
- 'reviewed',
31
- 'interested',
32
- 'applied',
33
- 'maybe',
34
- 'passed',
35
- ];
36
- const TAB_LABELS = {
37
- all: 'All',
38
- new: 'New',
39
- reviewed: 'Reviewed',
40
- interested: 'Interested',
41
- applied: 'Applied',
42
- maybe: 'Maybe',
43
- passed: 'Passed',
44
- };
45
-
46
- function InlineSearch({ query, onChange, onSubmit }) {
47
- return h(
48
- Box,
49
- { paddingX: 1, gap: 1 },
50
- h(Text, { color: 'yellow', bold: true }, 'Find:'),
51
- h(TextInput, { value: query, onChange, onSubmit }),
52
- h(Text, { dimColor: true }, ' Enter to apply, Esc to clear')
53
- );
54
- }
25
+ import { InlineSearch, SplitPane } from './SplitPane.js';
26
+ import {
27
+ TABS,
28
+ TAB_LABELS,
29
+ nextTab,
30
+ filterJobsByQuery,
31
+ computeCounts,
32
+ } from './jobFilters.js';
33
+ import { createAppKeyHandler } from './appKeyHandler.js';
55
34
 
56
35
  function App({ baseUrl, apiKey, apiClient }) {
57
36
  const { exit } = useApp();
@@ -151,22 +130,10 @@ function App({ baseUrl, apiKey, apiClient }) {
151
130
  }, [ai]);
152
131
 
153
132
  // Apply inline search filter
154
- const jobs = useMemo(() => {
155
- if (!appliedQuery) return rawJobs;
156
- const q = appliedQuery.toLowerCase();
157
- return rawJobs.filter((j) => {
158
- const fields = [
159
- j.title,
160
- j.company,
161
- j.description,
162
- j.remote,
163
- j.location?.city,
164
- j.location?.countryCode,
165
- ...(j.skills || []).map((s) => s.name || s),
166
- ];
167
- return fields.some((f) => f && String(f).toLowerCase().includes(q));
168
- });
169
- }, [rawJobs, appliedQuery]);
133
+ const jobs = useMemo(
134
+ () => filterJobsByQuery(rawJobs, appliedQuery),
135
+ [rawJobs, appliedQuery]
136
+ );
170
137
 
171
138
  useEffect(() => {
172
139
  api
@@ -182,82 +149,6 @@ function App({ baseUrl, apiKey, apiClient }) {
182
149
  }
183
150
  }, [cursor, view, jobs]);
184
151
 
185
- // Inline search escape handler
186
- useInput(
187
- (input, key) => {
188
- if (key.escape) {
189
- setInlineSearch(false);
190
- setSearchQuery('');
191
- setAppliedQuery('');
192
- }
193
- },
194
- { isActive: inlineSearch }
195
- );
196
-
197
- // Main input handler
198
- useInput(
199
- (input, key) => {
200
- if (view === 'filters' || view === 'searches' || view === 'help') return;
201
- if (inlineSearch) return;
202
-
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);
216
- if (input === 'q' && view === 'detail') setView('list');
217
- if (input === 'R' && (view === 'list' || view === 'detail')) {
218
- forceRefresh();
219
- showToast('Refreshing…', 'info');
220
- }
221
- if (input === 'f' && (view === 'list' || view === 'detail'))
222
- setView('filters');
223
- if (input === '/' && (view === 'list' || view === 'detail'))
224
- setView('searches');
225
- if (input === '?' && (view === 'list' || view === 'detail'))
226
- setView('help');
227
- if (input === 'n' && view === 'list') {
228
- setInlineSearch(true);
229
- setSearchQuery('');
230
- }
231
-
232
- // Enter toggles detail panel
233
- if (key.return && view === 'list' && jobs[cursor]) {
234
- setSelectedJob(jobs[cursor]);
235
- setView('detail');
236
- }
237
- if (key.escape && view === 'detail') setView('list');
238
- if (input === 'c' && view === 'detail' && selectedJob) {
239
- handleDossier(selectedJob);
240
- }
241
-
242
- if (key.escape && view === 'ai') {
243
- // Don't kill running dossier — just hide the panel
244
- setView(selectedJob ? 'detail' : 'list');
245
- }
246
-
247
- if (key.tab && (view === 'list' || view === 'detail')) {
248
- const idx = TABS.indexOf(tab);
249
- setTab(TABS[(idx + 1) % TABS.length]);
250
- setCursor(0);
251
- }
252
- if (key.shift && key.tab && (view === 'list' || view === 'detail')) {
253
- const idx = TABS.indexOf(tab);
254
- setTab(TABS[(idx - 1 + TABS.length) % TABS.length]);
255
- setCursor(0);
256
- }
257
- },
258
- { isActive: view !== 'filters' && view !== 'searches' && view !== 'help' }
259
- );
260
-
261
152
  const handleSelect = (job) => {
262
153
  setSelectedJob(job);
263
154
  setView('detail');
@@ -313,28 +204,50 @@ function App({ baseUrl, apiKey, apiClient }) {
313
204
  setCursor(0);
314
205
  };
315
206
 
207
+ // Inline search escape handler
208
+ useInput(
209
+ (input, key) => {
210
+ if (key.escape) {
211
+ setInlineSearch(false);
212
+ setSearchQuery('');
213
+ setAppliedQuery('');
214
+ }
215
+ },
216
+ { isActive: inlineSearch }
217
+ );
218
+
219
+ // Main input handler
220
+ useInput(
221
+ createAppKeyHandler({
222
+ view,
223
+ tab,
224
+ jobs,
225
+ cursor,
226
+ selectedJob,
227
+ inlineSearch,
228
+ confirmExit,
229
+ ai,
230
+ exit,
231
+ forceRefresh,
232
+ showToast,
233
+ setView,
234
+ setTab,
235
+ setCursor,
236
+ setSelectedJob,
237
+ setInlineSearch,
238
+ setSearchQuery,
239
+ setConfirmExit,
240
+ handleDossier,
241
+ nextTab,
242
+ }),
243
+ { isActive: view !== 'filters' && view !== 'searches' && view !== 'help' }
244
+ );
245
+
316
246
  const activeSearch = activeSearchId
317
247
  ? searchesHook.searches.find((s) => s.id === activeSearchId)
318
248
  : null;
319
249
 
320
- const counts = {
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,
332
- interested: allJobs.filter((j) => j.state === 'interested').length,
333
- applied: allJobs.filter((j) => j.state === 'applied').length,
334
- maybe: allJobs.filter((j) => j.state === 'maybe').length,
335
- passed: allJobs.filter((j) => j.state === 'not_interested').length,
336
- };
337
-
250
+ const counts = computeCounts(allJobs, ai.getDossierStatus);
338
251
  const toastEl = toast ? h(Toast, { toast }) : null;
339
252
 
340
253
  // ── Layout ──────────────────────────────────────────
@@ -360,130 +273,67 @@ function App({ baseUrl, apiKey, apiClient }) {
360
273
  toast: toastEl,
361
274
  });
362
275
 
276
+ // Shared left-pane list props for split-pane views.
277
+ const baseListProps = {
278
+ jobs,
279
+ cursor,
280
+ tab,
281
+ onCursorChange: setCursor,
282
+ onSelect: handleSelect,
283
+ onMark: handleMark,
284
+ onAISummary: handleAISummary,
285
+ onDossier: handleDossier,
286
+ getDossierStatus: ai.getDossierStatus,
287
+ };
288
+
363
289
  // Split-pane: compact list on left, detail on right
364
290
  if (view === 'detail' && selectedJob) {
365
- return h(
366
- Box,
367
- { flexDirection: 'column', height: process.stdout.rows || 40 },
291
+ return h(SplitPane, {
368
292
  header,
369
- h(
370
- Box,
371
- { flexGrow: 1, flexDirection: 'row' },
372
- // Left pane: compact job list
373
- h(
374
- Box,
375
- {
376
- flexDirection: 'column',
377
- width: '40%',
378
- borderStyle: 'single',
379
- borderColor: 'gray',
380
- borderRight: true,
381
- borderLeft: false,
382
- borderTop: false,
383
- borderBottom: false,
384
- },
385
- h(JobList, {
386
- jobs,
387
- cursor,
388
- tab,
389
- onCursorChange: setCursor,
390
- onSelect: handleSelect,
391
- onMark: handleMark,
392
- onAISummary: handleAISummary,
393
- onDossier: handleDossier,
394
- getDossierStatus: ai.getDossierStatus,
395
- isActive: true,
396
- compact: true,
397
- reservedRows: 8,
398
- })
399
- ),
400
- // Right pane: job detail
401
- h(
402
- Box,
403
- { flexDirection: 'column', width: '60%' },
404
- h(JobDetail, {
405
- job: selectedJob,
406
- api,
407
- onBack: handleBack,
408
- onMark: handleMark,
409
- onAISummary: handleAISummary,
410
- onDossier: handleDossier,
411
- getDossierStatus: ai.getDossierStatus,
412
- isActive: false,
413
- isPanel: true,
414
- })
415
- )
416
- ),
417
- statusBar
418
- );
293
+ statusBar,
294
+ listProps: { ...baseListProps, isActive: true },
295
+ right: h(JobDetail, {
296
+ job: selectedJob,
297
+ api,
298
+ onBack: handleBack,
299
+ onMark: handleMark,
300
+ onAISummary: handleAISummary,
301
+ onDossier: handleDossier,
302
+ getDossierStatus: ai.getDossierStatus,
303
+ isActive: false,
304
+ isPanel: true,
305
+ }),
306
+ });
419
307
  }
420
308
 
421
309
  // Split-pane: compact list on left, AI/dossier on right
422
310
  if (view === 'ai' && selectedJob) {
423
- return h(
424
- Box,
425
- { flexDirection: 'column', height: process.stdout.rows || 40 },
311
+ return h(SplitPane, {
426
312
  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
- );
313
+ statusBar,
314
+ listProps: { ...baseListProps, isActive: false },
315
+ right: h(AIPanel, {
316
+ text: ai.text,
317
+ loading: ai.loading,
318
+ error: ai.error,
319
+ mode: ai.mode,
320
+ job: selectedJob,
321
+ onMark: handleMark,
322
+ onDismiss: () => {
323
+ setView(selectedJob ? 'detail' : 'list');
324
+ },
325
+ onExport: () => {
326
+ const f = ai.exportDossier(selectedJob);
327
+ if (f) showToast(`Saved ./${f}`, 'export');
328
+ return f;
329
+ },
330
+ onRegenerate: (job) => {
331
+ ai.regenerateDossier(job, api);
332
+ showToast('Regenerating dossier…', 'info');
333
+ },
334
+ isActive: true,
335
+ }),
336
+ });
487
337
  }
488
338
 
489
339
  // Full-width list view
@@ -493,14 +343,7 @@ function App({ baseUrl, apiKey, apiClient }) {
493
343
  header,
494
344
  view === 'list'
495
345
  ? h(JobList, {
496
- jobs,
497
- cursor,
498
- tab,
499
- onCursorChange: setCursor,
500
- onSelect: handleSelect,
501
- onMark: handleMark,
502
- onAISummary: handleAISummary,
503
- onDossier: handleDossier,
346
+ ...baseListProps,
504
347
  onAIBatch: handleAIBatch,
505
348
  onExport: handleExport,
506
349
  isActive: !inlineSearch,