@jsonresume/jobs 0.9.0 → 0.11.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/README.md CHANGED
@@ -118,11 +118,12 @@ The TUI has three main regions:
118
118
  - **Export** — press `e` to export your shortlist, applied, and maybe lists to a `job-hunt-YYYY-MM-DD.md` markdown file in the current directory
119
119
  - **Toast notifications** — instant feedback on every action (marking, exporting, refreshing)
120
120
  - **Help modal** — press `?` for a full keyboard reference organized by section
121
+ - **Dossier research** — press `c` to spawn a Claude Code CLI session that researches the company, role, and generates a comprehensive dossier with fit assessment, talking points, interview prep, and compensation context. Results stream live and are cached server-side so you can revisit them anytime. Supports switching between multiple dossiers without restarting. Requires [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI installed
121
122
  - **AI summaries** — press `Space` for a per-job AI summary, or `S` for a batch review of all visible jobs (requires `OPENAI_API_KEY`)
122
123
  - **Vim-style navigation** — `j`/`k` to move, `g`/`G` to jump to first/last, `Ctrl+U`/`Ctrl+D` to page up/down
123
124
  - **Responsive columns** — job list columns (score, title, company, location, salary) adapt to terminal width on resize
124
125
  - **Smart filtering** — passed and dismissed jobs are excluded server-side with 5x over-fetch, so you always get a full set of fresh results
125
- - **Cached results** — job data is cached locally for 24 hours to minimize API calls; press `R` to force a fresh fetch
126
+ - **Cached results** — job data is cached locally for 2 hours to minimize API calls; press `R` to force a fresh fetch
126
127
 
127
128
  ### Keyboard Shortcuts
128
129
 
@@ -145,6 +146,7 @@ The TUI has three main regions:
145
146
  | `f` | Manage filters |
146
147
  | `/` | Search profiles |
147
148
  | `Space` | AI summary for current job |
149
+ | `c` | Research dossier via Claude Code CLI |
148
150
  | `S` | AI batch review of visible jobs |
149
151
  | `e` | Export shortlist to markdown |
150
152
  | `R` | Force refresh (bypass cache) |
@@ -160,6 +162,7 @@ The TUI has three main regions:
160
162
  | `o` | Open HN post in browser |
161
163
  | `i` / `x` / `m` / `p` | Mark job state |
162
164
  | `Space` | AI summary |
165
+ | `c` | Research dossier |
163
166
  | `Esc` / `q` | Back to full list |
164
167
 
165
168
  #### Filters Panel
@@ -262,6 +265,10 @@ npx @jsonresume/jobs help # All options
262
265
  | `not_interested` | ✗ | Not for you (hidden from future searches) |
263
266
  | `dismissed` | 👁 | Hide from results (hidden from future searches) |
264
267
 
268
+ ## Architecture
269
+
270
+ ![architecture](./architecture.svg)
271
+
265
272
  ## How Ranking Works
266
273
 
267
274
  The system uses a five-stage pipeline to match and rank jobs against your resume.
@@ -335,7 +342,7 @@ All local data is stored under `~/.jsonresume/`:
335
342
  |------|----------|
336
343
  | `~/.jsonresume/config.json` | API key and username (registry mode) |
337
344
  | `~/.jsonresume/filters.json` | Saved filter presets per search profile |
338
- | `~/.jsonresume/cache/` | Cached job results (auto-expires after 24 hours) |
345
+ | `~/.jsonresume/cache/` | Cached job results (auto-expires after 2 hours) |
339
346
  | `~/.jsonresume/local-marks.json` | Job marks in local mode |
340
347
 
341
348
  The export command writes `job-hunt-YYYY-MM-DD.md` to your current working directory.
@@ -363,7 +370,8 @@ The TUI is built with [React Ink](https://github.com/vadimdemedes/ink) v6 using
363
370
  | `src/tui/JobDetail.js` | Full job detail view (standalone and split-pane) |
364
371
  | `src/tui/StatusBar.js` | Key hints, loading state, toasts |
365
372
  | `src/tui/useJobs.js` | Job fetching, caching, tab filtering |
366
- | `src/tui/useAI.js` | AI summary and batch review integration |
373
+ | `src/tui/useAI.js` | AI summary, dossier research (Claude CLI), batch review |
374
+ | `src/tui/AIPanel.js` | Dossier/AI split-pane panel with scroll and export |
367
375
  | `src/tui/useSearches.js` | Search profile CRUD |
368
376
  | `src/filters.js` | Persistent filter storage per search profile |
369
377
  | `src/export.js` | Markdown export |
package/bin/cli.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * Just run: npx @jsonresume/jobs
7
7
  */
8
8
 
9
- const VERSION = '0.9.0';
9
+ const VERSION = '0.10.0';
10
10
 
11
11
  const BASE_URL =
12
12
  getArg('--base-url') ||
@@ -118,10 +118,12 @@ function stateIcon(state) {
118
118
 
119
119
  async function cmdSearch() {
120
120
  const params = new URLSearchParams();
121
- params.set('top', getArg('--top') || '20');
122
- params.set('days', getArg('--days') || '30');
121
+ const topArg = parseInt(getArg('--top')) || 20;
122
+ params.set('top', String(Math.min(Math.max(1, topArg), 100)));
123
+ params.set('days', String(parseInt(getArg('--days')) || 30));
123
124
  if (hasFlag('--remote')) params.set('remote', 'true');
124
- if (getArg('--min-salary')) params.set('min_salary', getArg('--min-salary'));
125
+ const minSalary = parseInt(getArg('--min-salary'));
126
+ if (minSalary > 0) params.set('min_salary', String(minSalary));
125
127
  if (getArg('--search')) params.set('search', getArg('--search'));
126
128
 
127
129
  const { jobs } = await api(`/jobs?${params}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsonresume/jobs",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "description": "Search Hacker News jobs matched against your JSON Resume",
6
6
  "bin": {
package/src/api.js CHANGED
@@ -40,7 +40,7 @@ export function createApiClient({ baseUrl, apiKey }) {
40
40
  markJob: (id, state, feedback) =>
41
41
  request(`/jobs/${id}`, {
42
42
  method: 'PUT',
43
- body: JSON.stringify({ state, feedback: feedback || state }),
43
+ body: JSON.stringify({ state, feedback: feedback || undefined }),
44
44
  }),
45
45
  fetchMe: () => request('/me'),
46
46
 
@@ -57,5 +57,13 @@ export function createApiClient({ baseUrl, apiKey }) {
57
57
  body: JSON.stringify(updates),
58
58
  }),
59
59
  deleteSearch: (id) => request(`/searches/${id}`, { method: 'DELETE' }),
60
+
61
+ // Dossiers
62
+ fetchDossier: (id) => request(`/jobs/${id}/dossier`),
63
+ saveDossier: (id, content) =>
64
+ request(`/jobs/${id}/dossier`, {
65
+ method: 'PUT',
66
+ body: JSON.stringify({ content }),
67
+ }),
60
68
  };
61
69
  }
package/src/cache.js CHANGED
@@ -3,7 +3,7 @@ import { join } from 'path';
3
3
  import { homedir } from 'os';
4
4
 
5
5
  const CACHE_DIR = join(homedir(), '.jsonresume', 'cache');
6
- const CACHE_TTL = 24 * 60 * 60 * 1000; // 1 day
6
+ const CACHE_TTL = 2 * 60 * 60 * 1000; // 2 hours
7
7
 
8
8
  function ensureDir() {
9
9
  try {
@@ -14,6 +14,7 @@ function ensureDir() {
14
14
  function cacheKey(params) {
15
15
  const parts = [`jobs_${params.days || 30}_${params.top || 100}`];
16
16
  if (params.searchId) parts.push(`s_${params.searchId.slice(0, 8)}`);
17
+ if (params.mode) parts.push(params.mode);
17
18
  return parts.join('_') + '.json';
18
19
  }
19
20
 
package/src/formatters.js CHANGED
@@ -39,26 +39,6 @@ export function truncate(str, len) {
39
39
  return str.length > len ? str.slice(0, len - 1) + '…' : str;
40
40
  }
41
41
 
42
- /**
43
- * Render a score as a sparkline bar: ████░░░░ 0.72
44
- * Uses Unicode block characters for sub-character precision.
45
- */
46
- const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
47
-
48
- export function scoreBar(score, width = 6) {
49
- if (typeof score !== 'number' || isNaN(score)) return '░'.repeat(width);
50
- const clamped = Math.max(0, Math.min(1, score));
51
- const filled = clamped * width;
52
- const full = Math.floor(filled);
53
- const frac = Math.round((filled - full) * 8);
54
- const empty = width - full - (frac > 0 ? 1 : 0);
55
- return (
56
- '█'.repeat(full) +
57
- (frac > 0 ? BLOCKS[frac] : '') +
58
- '░'.repeat(Math.max(0, empty))
59
- );
60
- }
61
-
62
42
  export function stateLabel(state) {
63
43
  const labels = {
64
44
  interested: 'Interested',
package/src/localApi.js CHANGED
@@ -10,6 +10,7 @@ export function createLocalApiClient({ baseUrl, resume }) {
10
10
  const base = baseUrl || DEFAULT_BASE_URL;
11
11
 
12
12
  return {
13
+ mode: 'local',
13
14
  fetchJobs: async (params = {}) => {
14
15
  const body = {
15
16
  resume,
@@ -25,7 +26,13 @@ export function createLocalApiClient({ baseUrl, resume }) {
25
26
  headers: { 'Content-Type': 'application/json' },
26
27
  body: JSON.stringify(body),
27
28
  });
28
- const data = await res.json();
29
+ const text = await res.text();
30
+ let data;
31
+ try {
32
+ data = JSON.parse(text);
33
+ } catch {
34
+ throw new Error(`Non-JSON response: ${res.status}`);
35
+ }
29
36
  if (!res.ok) throw new Error(data.error || res.statusText);
30
37
 
31
38
  // Overlay local marks onto results
@@ -44,9 +51,9 @@ export function createLocalApiClient({ baseUrl, resume }) {
44
51
  return { id };
45
52
  },
46
53
 
47
- markJob: async (id, state) => {
54
+ markJob: async (id, state, feedback) => {
48
55
  setMark(id, state);
49
- return { id, state };
56
+ return { id, state, feedback };
50
57
  },
51
58
 
52
59
  fetchMe: async () => ({ resume, username: 'local' }),
@@ -62,5 +69,9 @@ export function createLocalApiClient({ baseUrl, resume }) {
62
69
  deleteSearch: async () => {
63
70
  throw new Error('Search profiles require a registry account');
64
71
  },
72
+
73
+ // Dossiers — local mode stores in memory only
74
+ fetchDossier: async () => ({ content: null }),
75
+ saveDossier: async () => ({ saved: true }),
65
76
  };
66
77
  }
@@ -1,37 +1,135 @@
1
+ import { useState, useEffect, useRef } from 'react';
1
2
  import { Box, Text, useInput } from 'ink';
2
3
  import Spinner from 'ink-spinner';
3
4
  import { h } from './h.js';
4
5
 
5
- export default function AIPanel({ text, loading, error, onDismiss, isActive }) {
6
+ export default function AIPanel({
7
+ text,
8
+ loading,
9
+ error,
10
+ onDismiss,
11
+ onExport,
12
+ onMark,
13
+ job,
14
+ isActive,
15
+ mode,
16
+ }) {
17
+ const [scroll, setScroll] = useState(0);
18
+ const maxRows = Math.max((process.stdout.rows || 30) - 8, 10);
19
+ const exportMsg = useRef(null);
20
+
21
+ // Enable mouse reporting for scroll wheel
22
+ useEffect(() => {
23
+ if (!process.stdout.isTTY) return;
24
+ // Enable mouse wheel reporting (SGR mode)
25
+ process.stdout.write('\x1b[?1000h\x1b[?1006h');
26
+ const onData = (data) => {
27
+ const str = data.toString();
28
+ // SGR mouse: \x1b[<button;col;row(M|m)
29
+ const match = str.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
30
+ if (match) {
31
+ const btn = parseInt(match[1], 10);
32
+ if (btn === 64) setScroll((s) => Math.max(0, s - 3));
33
+ if (btn === 65) setScroll((s) => s + 3);
34
+ }
35
+ };
36
+ process.stdin.on('data', onData);
37
+ return () => {
38
+ process.stdin.removeListener('data', onData);
39
+ if (process.stdout.isTTY) {
40
+ process.stdout.write('\x1b[?1000l\x1b[?1006l');
41
+ }
42
+ };
43
+ }, []);
44
+
6
45
  useInput(
7
- (_input, key) => {
46
+ (input, key) => {
8
47
  if (key.escape) onDismiss();
48
+ if (input === 'e' && text && onExport) {
49
+ const filename = onExport();
50
+ if (filename) {
51
+ exportMsg.current = filename;
52
+ setTimeout(() => {
53
+ exportMsg.current = null;
54
+ }, 3000);
55
+ }
56
+ }
57
+ // Mark keys
58
+ if (job && onMark) {
59
+ if (input === 'i') onMark(job.id, 'interested');
60
+ if (input === 'x') onMark(job.id, 'applied');
61
+ if (input === 'm') onMark(job.id, 'maybe');
62
+ if (input === 'p') onMark(job.id, 'not_interested');
63
+ }
64
+ if (key.upArrow || input === 'k') setScroll((s) => Math.max(0, s - 1));
65
+ if (key.downArrow || input === 'j') setScroll((s) => s + 1);
66
+ if (key.pageUp || (key.ctrl && input === 'u'))
67
+ setScroll((s) => Math.max(0, s - 20));
68
+ if (key.pageDown || (key.ctrl && input === 'd')) setScroll((s) => s + 20);
69
+ if (input === 'g') setScroll(0);
70
+ if (input === 'G' && text) {
71
+ const lines = text.split('\n');
72
+ setScroll(Math.max(0, lines.length - maxRows));
73
+ }
9
74
  },
10
75
  { isActive }
11
76
  );
12
77
 
78
+ const isCover = mode === 'cover';
79
+ const title = isCover ? '📋 Job Dossier (via Claude Code)' : '🤖 AI Analysis';
80
+ const color = isCover ? 'green' : 'magenta';
81
+ const loadingMsg = isCover
82
+ ? ' Claude is researching… (streaming live)'
83
+ : ' Thinking...';
84
+
85
+ let displayText = text;
86
+ let totalLines = 0;
87
+ if (text) {
88
+ const lines = text.split('\n');
89
+ totalLines = lines.length;
90
+ if (loading) {
91
+ // While streaming, auto-scroll to bottom
92
+ displayText = lines.slice(Math.max(0, lines.length - maxRows)).join('\n');
93
+ } else {
94
+ // When done, allow manual scroll
95
+ const start = Math.min(scroll, Math.max(0, lines.length - maxRows));
96
+ displayText = lines.slice(start, start + maxRows).join('\n');
97
+ }
98
+ }
99
+
100
+ const scrollHint =
101
+ !loading && totalLines > maxRows
102
+ ? `j/k scroll · g/G top/bottom · ${scroll + 1}–${Math.min(
103
+ scroll + maxRows,
104
+ totalLines
105
+ )}/${totalLines}`
106
+ : '';
107
+
108
+ const exportHint = text ? ' · e export' : '';
109
+ const statusLine =
110
+ loading && text
111
+ ? 'Streaming… ESC to cancel'
112
+ : loading
113
+ ? 'ESC to cancel'
114
+ : scrollHint
115
+ ? `${scrollHint}${exportHint} · ESC to dismiss`
116
+ : `ESC to dismiss${exportHint}`;
117
+
13
118
  return h(
14
119
  Box,
15
120
  {
16
121
  flexDirection: 'column',
17
- borderStyle: 'double',
18
- borderColor: 'magenta',
19
- padding: 1,
20
- marginX: 2,
122
+ flexGrow: 1,
123
+ paddingX: 1,
21
124
  },
22
- h(Text, { bold: true, color: 'magenta' }, '🤖 AI Analysis'),
125
+ h(Text, { bold: true, color }, title),
23
126
  h(Text, null, ' '),
24
127
  loading
25
- ? h(
26
- Box,
27
- null,
28
- h(Spinner, { type: 'dots' }),
29
- h(Text, null, ' Thinking...')
30
- )
128
+ ? h(Box, null, h(Spinner, { type: 'dots' }), h(Text, null, loadingMsg))
31
129
  : null,
32
130
  error ? h(Text, { color: 'red' }, `Error: ${error}`) : null,
33
- text ? h(Text, { wrap: 'wrap' }, text) : null,
131
+ displayText ? h(Text, { wrap: 'wrap' }, displayText) : null,
34
132
  h(Text, null, ' '),
35
- h(Text, { dimColor: true }, 'Press ESC to dismiss')
133
+ h(Text, { dimColor: true }, statusLine)
36
134
  );
37
135
  }
package/src/tui/App.js CHANGED
@@ -36,9 +36,10 @@ const TAB_LABELS = {
36
36
  function InlineSearch({ query, onChange, onSubmit }) {
37
37
  return h(
38
38
  Box,
39
- { paddingX: 1 },
40
- h(Text, { color: 'yellow', bold: true }, 'Find: '),
41
- h(TextInput, { value: query, onChange, onSubmit })
39
+ { paddingX: 1, gap: 1 },
40
+ h(Text, { color: 'yellow', bold: true }, 'Find:'),
41
+ h(TextInput, { value: query, onChange, onSubmit }),
42
+ h(Text, { dimColor: true }, ' Enter to apply, Esc to clear')
42
43
  );
43
44
  }
44
45
 
@@ -64,7 +65,9 @@ function App({ baseUrl, apiKey, apiClient }) {
64
65
  // Active search profile
65
66
  const [activeSearchId, setActiveSearchId] = useState(null);
66
67
 
67
- // Persistent filters
68
+ const searchesHook = useSearches(api);
69
+
70
+ // Persistent filters (local + server sync for search profiles)
68
71
  const [filterStore, setFilterStore] = useState(() => loadFilters());
69
72
  const activeFilters = useMemo(
70
73
  () => getFiltersForSearch(filterStore, activeSearchId),
@@ -77,8 +80,12 @@ function App({ baseUrl, apiKey, apiClient }) {
77
80
  saveFilters(next);
78
81
  return next;
79
82
  });
83
+ // Sync to server for search profiles
84
+ if (activeSearchId) {
85
+ searchesHook.updateFilters(activeSearchId, newActive);
86
+ }
80
87
  },
81
- [activeSearchId]
88
+ [activeSearchId, searchesHook]
82
89
  );
83
90
  const filterState = useMemo(
84
91
  () => ({ active: activeFilters }),
@@ -89,6 +96,21 @@ function App({ baseUrl, apiKey, apiClient }) {
89
96
  [updateFilters]
90
97
  );
91
98
 
99
+ // Sync server-side filters into local store when search profiles load
100
+ useEffect(() => {
101
+ if (!searchesHook.searches.length) return;
102
+ setFilterStore((prev) => {
103
+ let updated = prev;
104
+ for (const s of searchesHook.searches) {
105
+ if (s.filters?.length && !prev.searches?.[s.id]) {
106
+ updated = setFiltersForSearch(updated, s.id, s.filters);
107
+ }
108
+ }
109
+ if (updated !== prev) saveFilters(updated);
110
+ return updated;
111
+ });
112
+ }, [searchesHook.searches]);
113
+
92
114
  const {
93
115
  jobs: rawJobs,
94
116
  allJobs,
@@ -99,14 +121,36 @@ function App({ baseUrl, apiKey, apiClient }) {
99
121
  forceRefresh,
100
122
  } = useJobs(api, activeFilters, tab, activeSearchId);
101
123
  const ai = useAI(resume);
102
- const searchesHook = useSearches(api);
103
124
  const { toast, show: showToast } = useToast();
125
+ const [confirmExit, setConfirmExit] = useState(false);
126
+
127
+ // Kill claude processes on exit (Ctrl+C)
128
+ useEffect(() => {
129
+ const cleanup = () => ai.cancel();
130
+ process.on('SIGINT', cleanup);
131
+ process.on('SIGTERM', cleanup);
132
+ return () => {
133
+ process.removeListener('SIGINT', cleanup);
134
+ process.removeListener('SIGTERM', cleanup);
135
+ };
136
+ }, [ai]);
104
137
 
105
138
  // Apply inline search filter
106
139
  const jobs = useMemo(() => {
107
140
  if (!appliedQuery) return rawJobs;
108
141
  const q = appliedQuery.toLowerCase();
109
- return rawJobs.filter((j) => JSON.stringify(j).toLowerCase().includes(q));
142
+ return rawJobs.filter((j) => {
143
+ const fields = [
144
+ j.title,
145
+ j.company,
146
+ j.description,
147
+ j.remote,
148
+ j.location?.city,
149
+ j.location?.countryCode,
150
+ ...(j.skills || []).map((s) => s.name || s),
151
+ ];
152
+ return fields.some((f) => f && String(f).toLowerCase().includes(q));
153
+ });
110
154
  }, [rawJobs, appliedQuery]);
111
155
 
112
156
  useEffect(() => {
@@ -116,12 +160,12 @@ function App({ baseUrl, apiKey, apiClient }) {
116
160
  .catch(() => {});
117
161
  }, [api]);
118
162
 
119
- // Update selectedJob when cursor moves in detail view
163
+ // Update selectedJob when cursor moves or jobs list changes in detail view
120
164
  useEffect(() => {
121
165
  if (view === 'detail' && jobs[cursor]) {
122
166
  setSelectedJob(jobs[cursor]);
123
167
  }
124
- }, [cursor, view]);
168
+ }, [cursor, view, jobs]);
125
169
 
126
170
  // Inline search escape handler
127
171
  useInput(
@@ -141,7 +185,19 @@ function App({ baseUrl, apiKey, apiClient }) {
141
185
  if (view === 'filters' || view === 'searches' || view === 'help') return;
142
186
  if (inlineSearch) return;
143
187
 
144
- if (input === 'q' && view === 'list') exit();
188
+ if (input === 'q' && view === 'list') {
189
+ if (ai.hasActiveProcess && !confirmExit) {
190
+ showToast(
191
+ 'Claude dossier still running — press q again to quit',
192
+ 'warning'
193
+ );
194
+ setConfirmExit(true);
195
+ return;
196
+ }
197
+ ai.cancel();
198
+ exit();
199
+ }
200
+ if (input !== 'q') setConfirmExit(false);
145
201
  if (input === 'q' && view === 'detail') setView('list');
146
202
  if (input === 'R' && (view === 'list' || view === 'detail')) {
147
203
  forceRefresh();
@@ -164,9 +220,12 @@ function App({ baseUrl, apiKey, apiClient }) {
164
220
  setView('detail');
165
221
  }
166
222
  if (key.escape && view === 'detail') setView('list');
223
+ if (input === 'c' && view === 'detail' && selectedJob) {
224
+ handleDossier(selectedJob);
225
+ }
167
226
 
168
227
  if (key.escape && view === 'ai') {
169
- ai.clear();
228
+ // Don't kill running dossier — just hide the panel
170
229
  setView(selectedJob ? 'detail' : 'list');
171
230
  }
172
231
 
@@ -207,6 +266,11 @@ function App({ baseUrl, apiKey, apiClient }) {
207
266
  setView('ai');
208
267
  ai.summarizeJob(job);
209
268
  };
269
+ const handleDossier = (job) => {
270
+ setSelectedJob(job);
271
+ setView('ai');
272
+ ai.dossier(job, api);
273
+ };
210
274
  const handleAIBatch = (visibleJobs) => {
211
275
  setView('ai');
212
276
  ai.batchReview(visibleJobs);
@@ -215,7 +279,7 @@ function App({ baseUrl, apiKey, apiClient }) {
215
279
  const handleExport = () => {
216
280
  try {
217
281
  const filename = exportShortlist(allJobs);
218
- showToast(`Exported to ${filename}`, 'export');
282
+ showToast(`Saved ./${filename}`, 'export');
219
283
  } catch (err) {
220
284
  showToast(`Export failed: ${err.message}`, 'error');
221
285
  }
@@ -300,6 +364,9 @@ function App({ baseUrl, apiKey, apiClient }) {
300
364
  onCursorChange: setCursor,
301
365
  onSelect: handleSelect,
302
366
  onMark: handleMark,
367
+ onAISummary: handleAISummary,
368
+ onDossier: handleDossier,
369
+ getDossierStatus: ai.getDossierStatus,
303
370
  isActive: true,
304
371
  compact: true,
305
372
  reservedRows: 8,
@@ -315,6 +382,8 @@ function App({ baseUrl, apiKey, apiClient }) {
315
382
  onBack: handleBack,
316
383
  onMark: handleMark,
317
384
  onAISummary: handleAISummary,
385
+ onDossier: handleDossier,
386
+ getDossierStatus: ai.getDossierStatus,
318
387
  isActive: false,
319
388
  isPanel: true,
320
389
  })
@@ -324,6 +393,70 @@ function App({ baseUrl, apiKey, apiClient }) {
324
393
  );
325
394
  }
326
395
 
396
+ // Split-pane: compact list on left, AI/dossier on right
397
+ if (view === 'ai' && selectedJob) {
398
+ return h(
399
+ Box,
400
+ { flexDirection: 'column', height: process.stdout.rows || 40 },
401
+ header,
402
+ h(
403
+ Box,
404
+ { flexGrow: 1, flexDirection: 'row' },
405
+ // Left pane: compact job list
406
+ h(
407
+ Box,
408
+ {
409
+ flexDirection: 'column',
410
+ width: '40%',
411
+ borderStyle: 'single',
412
+ borderColor: 'gray',
413
+ borderRight: true,
414
+ borderLeft: false,
415
+ borderTop: false,
416
+ borderBottom: false,
417
+ },
418
+ h(JobList, {
419
+ jobs,
420
+ cursor,
421
+ tab,
422
+ onCursorChange: setCursor,
423
+ onSelect: handleSelect,
424
+ onMark: handleMark,
425
+ onAISummary: handleAISummary,
426
+ onDossier: handleDossier,
427
+ getDossierStatus: ai.getDossierStatus,
428
+ isActive: false,
429
+ compact: true,
430
+ reservedRows: 8,
431
+ })
432
+ ),
433
+ // Right pane: AI/dossier panel
434
+ h(
435
+ Box,
436
+ { flexDirection: 'column', width: '60%' },
437
+ h(AIPanel, {
438
+ text: ai.text,
439
+ loading: ai.loading,
440
+ error: ai.error,
441
+ mode: ai.mode,
442
+ job: selectedJob,
443
+ onMark: handleMark,
444
+ onDismiss: () => {
445
+ setView(selectedJob ? 'detail' : 'list');
446
+ },
447
+ onExport: () => {
448
+ const f = ai.exportDossier(selectedJob);
449
+ if (f) showToast(`Saved ./${f}`, 'export');
450
+ return f;
451
+ },
452
+ isActive: true,
453
+ })
454
+ )
455
+ ),
456
+ statusBar
457
+ );
458
+ }
459
+
327
460
  // Full-width list view
328
461
  return h(
329
462
  Box,
@@ -338,6 +471,7 @@ function App({ baseUrl, apiKey, apiClient }) {
338
471
  onSelect: handleSelect,
339
472
  onMark: handleMark,
340
473
  onAISummary: handleAISummary,
474
+ onDossier: handleDossier,
341
475
  onAIBatch: handleAIBatch,
342
476
  onExport: handleExport,
343
477
  isActive: !inlineSearch,
@@ -372,14 +506,15 @@ function App({ baseUrl, apiKey, apiClient }) {
372
506
  onClose: () => setView('list'),
373
507
  })
374
508
  : null,
375
- view === 'ai'
509
+ view === 'ai' && !selectedJob
376
510
  ? h(AIPanel, {
377
511
  text: ai.text,
378
512
  loading: ai.loading,
379
513
  error: ai.error,
514
+ mode: ai.mode,
380
515
  onDismiss: () => {
381
516
  ai.clear();
382
- setView(selectedJob ? 'detail' : 'list');
517
+ setView('list');
383
518
  },
384
519
  isActive: true,
385
520
  })
@@ -124,7 +124,7 @@ export default function FilterManager({ filterState, onUpdate, onClose }) {
124
124
  ...next[editIdx],
125
125
  value:
126
126
  filter.type === 'minSalary' || filter.type === 'days'
127
- ? parseInt(val) || val
127
+ ? parseInt(val) || 0
128
128
  : val,
129
129
  };
130
130
  onUpdate({ ...filterState, active: next });
@@ -132,11 +132,7 @@ export default function FilterManager({ filterState, onUpdate, onClose }) {
132
132
  },
133
133
  })
134
134
  ),
135
- h(
136
- Text,
137
- { dimColor: true, marginTop: 1 },
138
- 'Enter to save, Ctrl+C to cancel'
139
- )
135
+ h(Text, { dimColor: true, marginTop: 1 }, 'Enter to save, Esc to cancel')
140
136
  );
141
137
  }
142
138
 
@@ -165,7 +161,7 @@ export default function FilterManager({ filterState, onUpdate, onClose }) {
165
161
  if (val.trim()) {
166
162
  const value =
167
163
  ft.type === 'minSalary' || ft.type === 'days'
168
- ? parseInt(val) || val
164
+ ? parseInt(val) || 0
169
165
  : val;
170
166
  onUpdate({
171
167
  ...filterState,