claude-controller 0.1.2 → 0.3.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.
Files changed (71) hide show
  1. package/README.md +2 -2
  2. package/bin/autoloop.sh +382 -0
  3. package/bin/ctl +1189 -0
  4. package/bin/native-app.py +6 -3
  5. package/bin/watchdog.sh +357 -0
  6. package/cognitive/__init__.py +14 -0
  7. package/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
  8. package/cognitive/__pycache__/dispatcher.cpython-314.pyc +0 -0
  9. package/cognitive/__pycache__/evaluator.cpython-314.pyc +0 -0
  10. package/cognitive/__pycache__/goal_engine.cpython-314.pyc +0 -0
  11. package/cognitive/__pycache__/learning.cpython-314.pyc +0 -0
  12. package/cognitive/__pycache__/orchestrator.cpython-314.pyc +0 -0
  13. package/cognitive/__pycache__/planner.cpython-314.pyc +0 -0
  14. package/cognitive/dispatcher.py +192 -0
  15. package/cognitive/evaluator.py +289 -0
  16. package/cognitive/goal_engine.py +232 -0
  17. package/cognitive/learning.py +189 -0
  18. package/cognitive/orchestrator.py +303 -0
  19. package/cognitive/planner.py +207 -0
  20. package/cognitive/prompts/analyst.md +31 -0
  21. package/cognitive/prompts/coder.md +22 -0
  22. package/cognitive/prompts/reviewer.md +33 -0
  23. package/cognitive/prompts/tester.md +21 -0
  24. package/cognitive/prompts/writer.md +25 -0
  25. package/config.sh +6 -1
  26. package/dag/__init__.py +5 -0
  27. package/dag/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/dag/__pycache__/graph.cpython-314.pyc +0 -0
  29. package/dag/graph.py +222 -0
  30. package/lib/jobs.sh +12 -1
  31. package/package.json +11 -5
  32. package/postinstall.sh +1 -1
  33. package/service/controller.sh +43 -11
  34. package/web/audit.py +122 -0
  35. package/web/checkpoint.py +80 -0
  36. package/web/config.py +2 -5
  37. package/web/handler.py +634 -473
  38. package/web/handler_fs.py +153 -0
  39. package/web/handler_goals.py +203 -0
  40. package/web/handler_jobs.py +372 -0
  41. package/web/handler_memory.py +203 -0
  42. package/web/handler_sessions.py +132 -0
  43. package/web/jobs.py +585 -13
  44. package/web/personas.py +419 -0
  45. package/web/pipeline.py +981 -0
  46. package/web/presets.py +506 -0
  47. package/web/projects.py +246 -0
  48. package/web/static/api.js +141 -0
  49. package/web/static/app.js +25 -1937
  50. package/web/static/attachments.js +144 -0
  51. package/web/static/base.css +497 -0
  52. package/web/static/context.js +204 -0
  53. package/web/static/dirs.js +246 -0
  54. package/web/static/form.css +763 -0
  55. package/web/static/goals.css +363 -0
  56. package/web/static/goals.js +300 -0
  57. package/web/static/i18n.js +625 -0
  58. package/web/static/index.html +215 -13
  59. package/web/static/{styles.css → jobs.css} +746 -1141
  60. package/web/static/jobs.js +1270 -0
  61. package/web/static/memoryview.js +117 -0
  62. package/web/static/personas.js +228 -0
  63. package/web/static/pipeline.css +338 -0
  64. package/web/static/pipelines.js +487 -0
  65. package/web/static/presets.js +244 -0
  66. package/web/static/send.js +135 -0
  67. package/web/static/settings-style.css +291 -0
  68. package/web/static/settings.js +81 -0
  69. package/web/static/stream.js +534 -0
  70. package/web/static/utils.js +131 -0
  71. package/web/webhook.py +210 -0
@@ -0,0 +1,1270 @@
1
+ /* ═══════════════════════════════════════════════
2
+ Jobs — 작업 목록 렌더링, CRUD, 후속 명령
3
+ ═══════════════════════════════════════════════ */
4
+
5
+ let expandedJobId = null;
6
+ let jobPollTimer = null;
7
+ let _jobFilterStatus = 'all';
8
+ let _jobFilterProject = 'all';
9
+ let _jobSearchQuery = '';
10
+ let _allJobs = [];
11
+ let _registeredProjects = [];
12
+ let _jobListCollapsed = localStorage.getItem('jobListCollapsed') === '1';
13
+ let _jobViewMode = localStorage.getItem('jobViewMode') || 'flat';
14
+ let _jobViewModeManual = localStorage.getItem('jobViewModeManual') === '1'; // 사용자가 수동 전환한 적 있는지
15
+ let _collapsedGroups = JSON.parse(localStorage.getItem('collapsedGroups') || '{}');
16
+ let _statsPeriod = 'all';
17
+ let _selectedProject = null;
18
+ let _selectedProjectInfo = null;
19
+ let _jobPage = 1;
20
+ let _jobLimit = 10;
21
+ let _jobPages = 1;
22
+ let _jobTotal = 0;
23
+
24
+ /* ── Stats ── */
25
+
26
+ function setStatsPeriod(period) {
27
+ _statsPeriod = period;
28
+ document.querySelectorAll('.stats-period-btn').forEach(btn => {
29
+ btn.classList.toggle('active', btn.dataset.period === period);
30
+ });
31
+ fetchStats();
32
+ }
33
+
34
+ async function fetchStats() {
35
+ try {
36
+ const data = await apiFetch(`/api/stats?period=${_statsPeriod}`);
37
+ renderStats(data);
38
+ } catch { /* 통계 실패는 무시 */ }
39
+ }
40
+
41
+ async function fetchRegisteredProjects() {
42
+ try {
43
+ const data = await apiFetch('/api/projects');
44
+ _registeredProjects = Array.isArray(data) ? data : [];
45
+ if (_allJobs.length > 0) _renderProjectStrip(_allJobs);
46
+ } catch { _registeredProjects = []; }
47
+ }
48
+
49
+ function renderStats(data) {
50
+ const jobs = data.jobs || {};
51
+ const total = jobs.total || 0;
52
+ const done = jobs.done || 0;
53
+ const failed = jobs.failed || 0;
54
+ const running = jobs.running || 0;
55
+
56
+ document.getElementById('statTotal').textContent =
57
+ t('stat_jobs_summary').replace('{total}', total).replace('{running}', running);
58
+
59
+ const rate = data.success_rate;
60
+ const el = document.getElementById('statSuccess');
61
+ if (rate != null) {
62
+ const pct = (rate * 100).toFixed(1);
63
+ el.textContent = t('stat_success').replace('{pct}', pct);
64
+ el.style.color = rate >= 0.8 ? '' : 'var(--danger, #ef4444)';
65
+ } else {
66
+ el.textContent = '-';
67
+ }
68
+
69
+ const avg = data.duration?.avg_ms;
70
+ if (avg != null) {
71
+ const sec = avg / 1000;
72
+ document.getElementById('statDuration').textContent =
73
+ sec >= 60 ? `avg ${(sec / 60).toFixed(1)}m` : `avg ${sec.toFixed(1)}s`;
74
+ } else {
75
+ document.getElementById('statDuration').textContent = '-';
76
+ }
77
+ }
78
+
79
+ function toggleJobListCollapse() {
80
+ _jobListCollapsed = !_jobListCollapsed;
81
+ localStorage.setItem('jobListCollapsed', _jobListCollapsed ? '1' : '0');
82
+ _applyJobListCollapse();
83
+ }
84
+
85
+ function _applyJobListCollapse() {
86
+ const wrap = document.getElementById('jobTableWrap');
87
+ const filterBar = document.getElementById('jobFilterBar');
88
+ const strip = document.getElementById('projectStrip');
89
+ const statsBar = document.getElementById('statsBar');
90
+ const detail = document.getElementById('projectDetail');
91
+ const btn = document.getElementById('btnCollapseJobs');
92
+ if (wrap) wrap.style.display = _jobListCollapsed ? 'none' : '';
93
+ if (filterBar) filterBar.style.display = _jobListCollapsed ? 'none' : '';
94
+ if (strip) strip.style.display = _jobListCollapsed ? 'none' : '';
95
+ if (statsBar) statsBar.style.display = _jobListCollapsed ? 'none' : '';
96
+ if (detail) detail.style.display = _jobListCollapsed ? 'none' : (_selectedProject ? '' : 'none');
97
+ if (btn) btn.classList.toggle('collapsed', _jobListCollapsed);
98
+ }
99
+
100
+ function setJobFilter(status) {
101
+ _jobFilterStatus = status;
102
+ document.querySelectorAll('.job-filter-btn').forEach(btn => {
103
+ btn.classList.toggle('active', btn.dataset.filter === status);
104
+ });
105
+ applyJobFilters();
106
+ }
107
+
108
+ function applyJobFilters() {
109
+ _jobSearchQuery = (document.getElementById('jobSearchInput')?.value || '').toLowerCase().trim();
110
+ renderJobs(_allJobs);
111
+ }
112
+
113
+ function setJobProjectFilter(project) {
114
+ _jobFilterProject = project;
115
+ const sel = document.getElementById('jobProjectSelect');
116
+ if (sel) sel.value = project;
117
+
118
+ if (project === 'all') {
119
+ _selectedProject = null;
120
+ _selectedProjectInfo = null;
121
+ _hideProjectDetail();
122
+ } else {
123
+ const projects = _extractProjects(_allJobs);
124
+ _selectedProject = projects.find(p => p.name === project) || null;
125
+ _selectedProjectInfo = null;
126
+ _showProjectDetail();
127
+ if (_selectedProject?.registered && _selectedProject.projectId) {
128
+ _fetchProjectInfo(_selectedProject.projectId);
129
+ }
130
+ }
131
+
132
+ applyJobFilters();
133
+ }
134
+
135
+ function _extractProjects(jobs) {
136
+ const map = {};
137
+
138
+ // 1) 등록된 프로젝트를 먼저 삽입 (이름·경로 우��� 사용)
139
+ for (const rp of _registeredProjects) {
140
+ const name = rp.name || formatCwd(rp.path);
141
+ const js = rp.job_stats || {};
142
+ map[name] = {
143
+ name,
144
+ cwd: rp.path,
145
+ projectId: rp.id,
146
+ registered: true,
147
+ count: js.total || 0,
148
+ running: js.running || 0,
149
+ };
150
+ }
151
+
152
+ // 2) job cwd에서 추출한 프로젝트 병합 — 등록 프로젝트와 경로가 같으면 통합
153
+ for (const job of jobs) {
154
+ const cwdName = formatCwd(job.cwd);
155
+ if (!cwdName || cwdName === '-') continue;
156
+
157
+ // 등록 프로젝트 중 같은 경로인지 확인
158
+ const matchKey = Object.keys(map).find(k => {
159
+ const mp = map[k];
160
+ return mp.cwd && job.cwd && _normPath(mp.cwd) === _normPath(job.cwd);
161
+ });
162
+
163
+ if (matchKey) {
164
+ // 등록 프로젝트와 매칭 — job 단위 카운트는 이미 job_stats에 있으므로 skip
165
+ continue;
166
+ }
167
+
168
+ // 등록되지 않은 ad-hoc 프로젝트
169
+ if (!map[cwdName]) map[cwdName] = { name: cwdName, cwd: job.cwd, count: 0, running: 0, registered: false };
170
+ map[cwdName].count++;
171
+ if (job.status === 'running') map[cwdName].running++;
172
+ }
173
+
174
+ // 등록 프로젝트 우선, 그 안에서 count 역순
175
+ return Object.values(map).sort((a, b) => {
176
+ if (a.registered !== b.registered) return a.registered ? -1 : 1;
177
+ return b.count - a.count;
178
+ });
179
+ }
180
+
181
+ function _normPath(p) {
182
+ if (!p) return '';
183
+ return p.replace(/\/+$/, '');
184
+ }
185
+
186
+ function _updateProjectDropdown(jobs) {
187
+ const sel = document.getElementById('jobProjectSelect');
188
+ if (!sel) return;
189
+ const projects = _extractProjects(jobs);
190
+ const prev = sel.value;
191
+ sel.innerHTML = `<option value="all">${t('all_projects')} (${jobs.length})</option>`;
192
+ for (const p of projects) {
193
+ const label = p.name + ` (${p.count})`;
194
+ sel.innerHTML += `<option value="${escapeHtml(p.name)}">${escapeHtml(label)}</option>`;
195
+ }
196
+ sel.value = (prev && projects.some(p => p.name === prev)) ? prev : 'all';
197
+ _jobFilterProject = sel.value;
198
+ }
199
+
200
+ function _renderProjectStrip(jobs) {
201
+ const container = document.getElementById('projectStrip');
202
+ if (!container) return;
203
+ if (_jobListCollapsed) { container.style.display = 'none'; return; }
204
+
205
+ const projects = _extractProjects(jobs);
206
+ const dropdown = document.querySelector('.job-project-filter');
207
+
208
+ // 등록 프로젝트가 있으면 항상 스트립 표시
209
+ const hasRegistered = _registeredProjects.length > 0;
210
+ if (projects.length <= 1 && !hasRegistered) {
211
+ container.style.display = 'none';
212
+ if (dropdown) dropdown.style.display = projects.length > 0 ? 'flex' : 'none';
213
+ return;
214
+ }
215
+ container.style.display = '';
216
+ if (dropdown) dropdown.style.display = 'none';
217
+
218
+ // 프로젝트별 상태 카운트 (job 데이터에서 실시간 집계)
219
+ const sc = {};
220
+ for (const job of jobs) {
221
+ const name = _resolveProjectName(job.cwd);
222
+ if (!sc[name]) sc[name] = { running: 0, done: 0, failed: 0 };
223
+ const s = job.status;
224
+ if (s === 'running') sc[name].running++;
225
+ else if (s === 'done') sc[name].done++;
226
+ else if (s === 'failed') sc[name].failed++;
227
+ }
228
+
229
+ let html = `<button class="project-chip${_jobFilterProject === 'all' ? ' active' : ''}" onclick="setJobProjectFilter('all')">
230
+ <svg class="pchip-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
231
+ <span class="pchip-name">${t('all_projects')}</span>
232
+ <span class="pchip-count">${jobs.length}</span>
233
+ </button>`;
234
+
235
+ for (const p of projects) {
236
+ const active = _jobFilterProject === p.name ? ' active' : '';
237
+ const dot = p.running > 0 ? '<span class="pchip-dot"></span>' : '';
238
+ const s = sc[p.name] || {};
239
+ let stats = '';
240
+ if (s.running > 0) stats += `<span class="pchip-stat pchip-stat-running">${s.running}</span>`;
241
+ if (s.done > 0) stats += `<span class="pchip-stat pchip-stat-done">${s.done}</span>`;
242
+ if (s.failed > 0) stats += `<span class="pchip-stat pchip-stat-failed">${s.failed}</span>`;
243
+
244
+ const regBadge = p.registered ? '<span class="pchip-reg"></span>' : '';
245
+
246
+ html += `<button class="project-chip${active}${p.registered ? ' registered' : ''}" onclick="setJobProjectFilter('${escapeHtml(escapeJsStr(p.name))}')" title="${escapeHtml(p.cwd)}">
247
+ ${dot}${regBadge}
248
+ <svg class="pchip-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
249
+ <span class="pchip-name">${escapeHtml(p.name)}</span>
250
+ <span class="pchip-count">${p.count}</span>
251
+ ${stats}
252
+ </button>`;
253
+ }
254
+
255
+ container.innerHTML = html;
256
+ }
257
+
258
+ /** job의 cwd를 등록 프로젝트 이름으로 resolve한다. 매칭 실패 시 formatCwd 사용. */
259
+ function _resolveProjectName(cwd) {
260
+ if (!cwd) return '-';
261
+ const norm = _normPath(cwd);
262
+ for (const rp of _registeredProjects) {
263
+ if (_normPath(rp.path) === norm) return rp.name || formatCwd(rp.path);
264
+ }
265
+ return formatCwd(cwd);
266
+ }
267
+
268
+ /* ── Project Detail Panel ── */
269
+
270
+ async function _fetchProjectInfo(projectId) {
271
+ try {
272
+ const data = await apiFetch(`/api/projects/${projectId}`);
273
+ _selectedProjectInfo = data;
274
+ _showProjectDetail();
275
+ } catch { /* silent */ }
276
+ }
277
+
278
+ function _showProjectDetail() {
279
+ const container = document.getElementById('projectDetail');
280
+ if (!container || !_selectedProject) { _hideProjectDetail(); return; }
281
+
282
+ const projectJobs = _allJobs.filter(j => _resolveProjectName(j.cwd) === _selectedProject.name);
283
+
284
+ let running = 0, done = 0, failed = 0, totalDuration = 0, durCount = 0;
285
+ for (const j of projectJobs) {
286
+ if (j.status === 'running') running++;
287
+ else if (j.status === 'done') done++;
288
+ else if (j.status === 'failed') failed++;
289
+ if (j.duration_ms != null) { totalDuration += j.duration_ms; durCount++; }
290
+ }
291
+
292
+ const completed = done + failed;
293
+ const successRate = completed > 0 ? Math.round((done / completed) * 100) : null;
294
+ const avgDuration = durCount > 0 ? totalDuration / durCount / 1000 : null;
295
+
296
+ const info = _selectedProjectInfo;
297
+ const metaItems = [];
298
+ if (_selectedProject.cwd) metaItems.push(`<span class="pd-path-text">${escapeHtml(_selectedProject.cwd)}</span>`);
299
+ if (info?.branch) metaItems.push(`<span class="pd-branch">${escapeHtml(info.branch)}</span>`);
300
+ if (info?.remote) {
301
+ const remote = info.remote.replace(/\.git$/, '').replace(/^https?:\/\//, '').replace(/^git@([^:]+):/, '$1/');
302
+ metaItems.push(`<span class="pd-remote" title="${escapeHtml(info.remote)}">${escapeHtml(remote)}</span>`);
303
+ }
304
+
305
+ let statsHtml = `<div class="pd-stat"><div class="pd-stat-val">${projectJobs.length}</div><div class="pd-stat-label">${escapeHtml(t('pd_total'))}</div></div>`;
306
+ if (running > 0) statsHtml += `<div class="pd-stat pd-running"><div class="pd-stat-val">${running}</div><div class="pd-stat-label">${escapeHtml(t('pd_running'))}</div></div>`;
307
+ statsHtml += `<div class="pd-stat pd-done"><div class="pd-stat-val">${done}</div><div class="pd-stat-label">${escapeHtml(t('pd_done'))}</div></div>`;
308
+ if (failed > 0) statsHtml += `<div class="pd-stat pd-failed"><div class="pd-stat-val">${failed}</div><div class="pd-stat-label">${escapeHtml(t('pd_failed'))}</div></div>`;
309
+ if (avgDuration != null) {
310
+ const durStr = avgDuration < 60 ? `${avgDuration.toFixed(1)}s` : `${(avgDuration / 60).toFixed(1)}m`;
311
+ statsHtml += `<div class="pd-stat"><div class="pd-stat-val">${durStr}</div><div class="pd-stat-label">${escapeHtml(t('pd_avg'))}</div></div>`;
312
+ }
313
+ if (successRate != null) {
314
+ const rateClass = successRate >= 80 ? 'pd-rate-ok' : 'pd-rate-warn';
315
+ statsHtml += `<div class="pd-stat ${rateClass}"><div class="pd-stat-val">${successRate}%</div><div class="pd-stat-label">${escapeHtml(t('pd_success_rate'))}</div></div>`;
316
+ }
317
+
318
+ const regBadge = _selectedProject.registered ? `<span class="pd-badge">${escapeHtml(t('pd_registered'))}</span>` : '';
319
+ const cwdAttr = escapeHtml(escapeJsStr(_selectedProject.cwd || ''));
320
+
321
+ container.innerHTML = `
322
+ <div class="pd-header">
323
+ <div class="pd-title-row">
324
+ <svg class="pd-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
325
+ <span class="pd-name">${escapeHtml(_selectedProject.name)}</span>
326
+ ${regBadge}
327
+ <div class="pd-actions">
328
+ <button class="btn btn-sm btn-primary" onclick="sendTaskToProject('${cwdAttr}')" title="${escapeHtml(t('pd_send_task_title'))}">
329
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
330
+ ${escapeHtml(t('pd_send_task'))}
331
+ </button>
332
+ <button class="pd-close" onclick="setJobProjectFilter('all')" title="${escapeHtml(t('pd_show_all_title'))}">
333
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
334
+ </button>
335
+ </div>
336
+ </div>
337
+ ${metaItems.length ? `<div class="pd-meta">${metaItems.join(' <span class="pd-sep">\u00b7</span> ')}</div>` : ''}
338
+ </div>
339
+ <div class="pd-stats">${statsHtml}</div>
340
+ `;
341
+ container.style.display = '';
342
+ }
343
+
344
+ function _hideProjectDetail() {
345
+ const container = document.getElementById('projectDetail');
346
+ if (container) { container.style.display = 'none'; container.innerHTML = ''; }
347
+ }
348
+
349
+ function sendTaskToProject(cwd) {
350
+ if (cwd) {
351
+ addRecentDir(cwd);
352
+ selectRecentDir(cwd, true);
353
+ }
354
+ document.getElementById('promptInput')?.focus();
355
+ window.scrollTo({ top: 0, behavior: 'smooth' });
356
+ }
357
+
358
+ /* ── Grouped View ── */
359
+
360
+ function toggleJobViewMode() {
361
+ _jobViewMode = _jobViewMode === 'flat' ? 'grouped' : 'flat';
362
+ _jobViewModeManual = true;
363
+ localStorage.setItem('jobViewMode', _jobViewMode);
364
+ localStorage.setItem('jobViewModeManual', '1');
365
+ renderJobs(_allJobs);
366
+ }
367
+
368
+ /** 프로젝트별 통계 계산 — duration, success rate */
369
+ function _calcProjectStats(jobs) {
370
+ const stats = {};
371
+ for (const job of jobs) {
372
+ const name = _resolveProjectName(job.cwd);
373
+ const key = (name && name !== '-') ? name : t('pd_other');
374
+ if (!stats[key]) stats[key] = { duration: 0, done: 0, failed: 0, durCount: 0 };
375
+ const s = stats[key];
376
+ if (job.duration_ms != null) { s.duration += job.duration_ms; s.durCount++; }
377
+ if (job.status === 'done') s.done++;
378
+ if (job.status === 'failed') s.failed++;
379
+ }
380
+ return stats;
381
+ }
382
+
383
+ function _updateViewModeUI() {
384
+ const btn = document.getElementById('btnViewMode');
385
+ if (btn) btn.classList.toggle('active', _jobViewMode === 'grouped');
386
+ }
387
+
388
+ function toggleGroupCollapse(groupName) {
389
+ _collapsedGroups[groupName] = !_collapsedGroups[groupName];
390
+ localStorage.setItem('collapsedGroups', JSON.stringify(_collapsedGroups));
391
+ renderJobs(_allJobs);
392
+ }
393
+
394
+ function _renderGroupedView(jobs, tbody) {
395
+ jobs.sort((a, b) => {
396
+ const aR = a.status === 'running' ? 0 : 1;
397
+ const bR = b.status === 'running' ? 0 : 1;
398
+ if (aR !== bR) return aR - bR;
399
+ return (parseInt(b.id || b.job_id || 0)) - (parseInt(a.id || a.job_id || 0));
400
+ });
401
+
402
+ const groups = new Map();
403
+ for (const job of jobs) {
404
+ const name = _resolveProjectName(job.cwd);
405
+ const key = (name && name !== '-') ? name : t('pd_other');
406
+ if (!groups.has(key)) groups.set(key, { name: key, cwd: job.cwd, jobs: [] });
407
+ groups.get(key).jobs.push(job);
408
+ }
409
+
410
+ const sorted = [...groups.values()].sort((a, b) => {
411
+ const aR = a.jobs.some(j => j.status === 'running') ? 0 : 1;
412
+ const bR = b.jobs.some(j => j.status === 'running') ? 0 : 1;
413
+ if (aR !== bR) return aR - bR;
414
+ return b.jobs.length - a.jobs.length;
415
+ });
416
+
417
+ // 프로젝트별 통계
418
+ const pStats = _calcProjectStats(jobs);
419
+
420
+ // 포커스·expand row 보존
421
+ const activeEl = document.activeElement;
422
+ const focusId = activeEl?.classList.contains('followup-input') ? activeEl.id : null;
423
+ const focusVal = focusId ? activeEl.value : null;
424
+ const focusCur = focusId ? activeEl.selectionStart : null;
425
+
426
+ const savedExpands = new Map();
427
+ for (const tr of [...tbody.querySelectorAll('tr.expand-row')]) {
428
+ savedExpands.set(tr.dataset.jobId, tr);
429
+ tr.remove();
430
+ }
431
+
432
+ tbody.innerHTML = '';
433
+
434
+ if (sorted.length === 0) {
435
+ tbody.innerHTML = `<tr data-job-id="__empty__"><td colspan="6" class="empty-state">
436
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:40px;height:40px;margin-bottom:12px;opacity:0.3;"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>
437
+ <div>${t('no_jobs')}</div></td></tr>`;
438
+ document.getElementById('btnDeleteCompleted').style.display = 'none';
439
+ return;
440
+ }
441
+
442
+ let hasCompleted = false;
443
+
444
+ for (const group of sorted) {
445
+ const collapsed = !!_collapsedGroups[group.name];
446
+ const running = group.jobs.filter(j => j.status === 'running').length;
447
+ const done = group.jobs.filter(j => j.status === 'done').length;
448
+ const failed = group.jobs.filter(j => j.status === 'failed').length;
449
+ if (done > 0 || failed > 0) hasCompleted = true;
450
+
451
+ let statsHtml = '';
452
+ if (running > 0) statsHtml += `<span class="grp-stat grp-stat-running"><span class="grp-dot"></span>${running}</span>`;
453
+ if (done > 0) statsHtml += `<span class="grp-stat grp-stat-done">${done}</span>`;
454
+ if (failed > 0) statsHtml += `<span class="grp-stat grp-stat-failed">${failed}</span>`;
455
+
456
+ // 프로젝트별 메트릭 (소요시간, 성공률)
457
+ const ps = pStats[group.name] || {};
458
+ let metaHtml = '';
459
+ if (ps.durCount > 0) {
460
+ const avg = ps.duration / ps.durCount / 1000;
461
+ metaHtml += `<span class="grp-meta grp-meta-dur">avg ${avg < 60 ? avg.toFixed(1) + 's' : (avg / 60).toFixed(1) + 'm'}</span>`;
462
+ }
463
+ const completed = ps.done + ps.failed;
464
+ if (completed > 0) {
465
+ const rate = Math.round((ps.done / completed) * 100);
466
+ const rateClass = rate >= 80 ? 'grp-meta-rate-ok' : 'grp-meta-rate-warn';
467
+ metaHtml += `<span class="grp-meta ${rateClass}">${rate}%</span>`;
468
+ }
469
+
470
+ const hdr = document.createElement('tr');
471
+ hdr.className = 'job-group-row';
472
+ hdr.dataset.jobId = `__group__${group.name}`;
473
+ hdr.setAttribute('onclick', `toggleGroupCollapse('${escapeJsStr(group.name)}')`);
474
+ hdr.innerHTML = `<td colspan="6" class="job-group-cell"><div class="job-group-content">
475
+ <svg class="job-group-chevron${collapsed ? ' collapsed' : ''}" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
476
+ <svg class="job-group-folder" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
477
+ <span class="job-group-name">${escapeHtml(group.name)}</span>
478
+ <span class="job-group-count">${group.jobs.length}</span>
479
+ <div class="job-group-stats">${statsHtml}</div>
480
+ ${metaHtml ? `<div class="job-group-meta">${metaHtml}</div>` : ''}
481
+ </div></td>`;
482
+ tbody.appendChild(hdr);
483
+
484
+ if (collapsed) continue;
485
+
486
+ for (const job of group.jobs) {
487
+ const id = job.id || job.job_id || '-';
488
+ const isExpanded = expandedJobId === id;
489
+ if (streamState[id]) streamState[id].jobData = job;
490
+
491
+ const tr = document.createElement('tr');
492
+ tr.dataset.jobId = id;
493
+ tr.className = `job-group-item${isExpanded ? ' expanded' : ''}`;
494
+ tr.setAttribute('onclick', `toggleJobExpand('${escapeHtml(id)}')`);
495
+ tr.innerHTML = _buildJobRowCells(id, job);
496
+ tbody.appendChild(tr);
497
+
498
+ if (isExpanded) {
499
+ const eKey = id + '__expand';
500
+ if (savedExpands.has(eKey)) {
501
+ tbody.appendChild(savedExpands.get(eKey));
502
+ savedExpands.delete(eKey);
503
+ if (job.session_id) {
504
+ const panel = document.getElementById(`streamPanel-${id}`);
505
+ if (panel) panel.dataset.sessionId = job.session_id;
506
+ }
507
+ } else {
508
+ const eTr = document.createElement('tr');
509
+ eTr.className = 'expand-row';
510
+ eTr.dataset.jobId = eKey;
511
+ eTr.innerHTML = _buildExpandRowHtml(id, job);
512
+ tbody.appendChild(eTr);
513
+ initStream(id, job);
514
+ }
515
+ }
516
+
517
+ if (job.status === 'running' && !isExpanded) {
518
+ if (!streamState[id]) {
519
+ streamState[id] = { offset: 0, timer: null, done: false, jobData: job, events: [], renderedCount: 0, _initTime: Date.now(), _lastEventTime: Date.now() };
520
+ }
521
+ if (!streamState[id].timer) initStream(id, job);
522
+ const pvTr = document.createElement('tr');
523
+ pvTr.className = 'preview-row';
524
+ pvTr.dataset.jobId = id + '__preview';
525
+ pvTr.innerHTML = `<td colspan="6"><div class="job-preview" id="jobPreview-${escapeHtml(id)}"><span class="preview-text">${escapeHtml(t('stream_preview_wait'))}</span></div></td>`;
526
+ tbody.appendChild(pvTr);
527
+ updateJobPreview(id);
528
+ }
529
+ }
530
+ }
531
+
532
+ document.getElementById('btnDeleteCompleted').style.display = hasCompleted ? 'inline-flex' : 'none';
533
+
534
+ // 포커스 복원
535
+ if (focusId) {
536
+ const el = document.getElementById(focusId);
537
+ if (el) { el.value = focusVal || ''; el.focus(); if (focusCur !== null) el.setSelectionRange(focusCur, focusCur); }
538
+ }
539
+ }
540
+
541
+ function filterJobs(jobs) {
542
+ return jobs.filter(job => {
543
+ if (_jobFilterStatus !== 'all' && job.status !== _jobFilterStatus) return false;
544
+ if (_jobFilterProject !== 'all' && _resolveProjectName(job.cwd) !== _jobFilterProject) return false;
545
+ if (_jobSearchQuery && !(job.prompt || '').toLowerCase().includes(_jobSearchQuery)) return false;
546
+ return true;
547
+ });
548
+ }
549
+
550
+ const ZOMBIE_THRESHOLD_MS = 5 * 60 * 1000; // 5분간 스트림 데이터 없으면 좀비 의심
551
+
552
+ function statusBadgeHtml(status, jobId, job) {
553
+ const s = (status || 'unknown').toLowerCase();
554
+ const labels = { running: t('status_running'), done: t('status_done'), failed: t('status_failed'), pending: t('status_pending') };
555
+ const cls = { running: 'badge-running', done: 'badge-done', failed: 'badge-failed', pending: 'badge-pending' };
556
+ let badge = `<span class="badge ${cls[s] || 'badge-pending'}">${labels[s] || s}</span>`;
557
+ if (s === 'running' && jobId && isZombieJob(jobId)) {
558
+ badge += ` <span class="badge badge-zombie" title="${escapeHtml(t('job_zombie_title'))}">${escapeHtml(t('job_zombie'))}</span>`;
559
+ }
560
+ if (job && (s === 'done' || s === 'failed') && job.duration_ms != null) {
561
+ const info = formatDuration(job.duration_ms);
562
+ if (info) badge += `<span class="job-meta-info">${escapeHtml(info)}</span>`;
563
+ }
564
+ // 의존성 뱃지
565
+ if (job && job.depends_on && job.depends_on.length > 0) {
566
+ const depIds = job.depends_on.map(d => '#' + d).join(', ');
567
+ badge += ` <span class="badge badge-dep" title="${escapeHtml(t('job_dep_title').replace('{deps}', depIds))}">⛓ ${escapeHtml(depIds)}</span>`;
568
+ }
569
+ return badge;
570
+ }
571
+
572
+ function isZombieJob(jobId) {
573
+ const state = streamState[jobId];
574
+ if (!state) return false;
575
+ const lastEventTime = state._lastEventTime || state._initTime;
576
+ if (!lastEventTime) return false;
577
+ return (Date.now() - lastEventTime) > ZOMBIE_THRESHOLD_MS;
578
+ }
579
+
580
+ function jobActionsHtml(id, status, sessionId, cwd) {
581
+ const isRunning = status === 'running';
582
+ const escapedCwd = escapeHtml(escapeJsStr(cwd || ''));
583
+ let btns = '';
584
+ if (!isRunning) {
585
+ btns += `<button class="btn-retry-job" onclick="event.stopPropagation(); retryJob('${escapeHtml(id)}')" title="${escapeHtml(t('job_retry_title'))}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></button>`;
586
+ }
587
+ if (sessionId) {
588
+ btns += `<button class="btn-continue-job" onclick="event.stopPropagation(); openFollowUp('${escapeHtml(id)}')" title="${escapeHtml(t('job_resume_title'))}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></svg></button>`;
589
+ btns += `<button class="btn-fork-job" onclick="event.stopPropagation(); quickForkSession('${escapeHtml(sessionId)}', '${escapedCwd}')" title="${escapeHtml(t('job_fork_title'))}" style="color:var(--yellow);"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M6 9v3c0 3.3 2.7 6 6 6h3"/></svg></button>`;
590
+ }
591
+ if (!isRunning) {
592
+ btns += `<button class="btn-delete-job" onclick="event.stopPropagation(); deleteJob('${escapeHtml(id)}')" title="${escapeHtml(t('job_delete_title'))}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>`;
593
+ }
594
+ if (!btns) return '';
595
+ return `<div style="display:flex; align-items:center; gap:4px;">${btns}</div>`;
596
+ }
597
+
598
+ /** job row의 6개 셀 HTML을 생성한다 — flat/grouped 뷰 공용. */
599
+ function _buildJobRowCells(id, job) {
600
+ return `
601
+ <td class="job-id">${escapeHtml(String(id).slice(0, 8))}</td>
602
+ <td>${statusBadgeHtml(job.status, id, job)}</td>
603
+ <td class="prompt-cell" title="${escapeHtml(job.prompt)}">${renderPromptHtml(job.prompt)}</td>
604
+ <td class="job-cwd" title="${escapeHtml(job.cwd || '')}">${escapeHtml(formatCwd(job.cwd))}</td>
605
+ <td class="job-session${job.session_id ? ' clickable' : ''}" title="${escapeHtml(job.session_id || '')}" ${job.session_id ? `onclick="event.stopPropagation(); resumeFromJob('${escapeHtml(escapeJsStr(job.session_id))}', '${escapeHtml(escapeJsStr(truncate(job.prompt, 40)))}', '${escapeHtml(escapeJsStr(job.cwd || ''))}')"` : ''}>${job.session_id ? escapeHtml(job.session_id.slice(0, 8)) : (job.status === 'running' ? '<span style="color:var(--text-muted);font-size:0.7rem;">—</span>' : '-')}</td>
606
+ <td>${jobActionsHtml(id, job.status, job.session_id, job.cwd)}</td>`;
607
+ }
608
+
609
+ /** expand row의 inner HTML을 생성한다 — flat/grouped 뷰 공용. */
610
+ function _buildExpandRowHtml(id, job) {
611
+ const sessionId = job.session_id || '';
612
+ const jobCwd = job.cwd || '';
613
+ const eid = escapeHtml(id);
614
+
615
+ let actionsHtml = '';
616
+ if (job.status !== 'running') {
617
+ actionsHtml = `<div class="stream-actions">
618
+ <button class="btn btn-sm" onclick="event.stopPropagation(); retryJob('${eid}')"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg> ${escapeHtml(t('job_retry'))}</button>
619
+ <button class="btn btn-sm" onclick="event.stopPropagation(); copyStreamResult('${eid}')"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> ${escapeHtml(t('stream_copy_all'))}</button>
620
+ <button class="btn btn-sm" onclick="event.stopPropagation(); toggleCheckpointPanel('${eid}')"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg> ${escapeHtml(t('checkpoints'))}</button>
621
+ <button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); deleteJob('${eid}')"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> ${escapeHtml(t('stream_delete_job'))}</button>
622
+ </div>`;
623
+ }
624
+
625
+ let followupHtml = '';
626
+ if (job.status !== 'running' && sessionId) {
627
+ followupHtml = `<div class="stream-followup">
628
+ <span class="stream-followup-label">${escapeHtml(t('stream_followup_label'))}</span>
629
+ <div class="followup-input-wrap">
630
+ <input type="text" class="followup-input" id="followupInput-${eid}" placeholder="${escapeHtml(t('stream_followup_placeholder'))}" onkeydown="if(event.key==='Enter'){event.stopPropagation();sendFollowUp('${eid}')}" onclick="event.stopPropagation()">
631
+ <button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); sendFollowUp('${eid}')" style="white-space:nowrap;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> ${escapeHtml(t('send'))}</button>
632
+ </div>
633
+ </div>`;
634
+ }
635
+
636
+ return `<td colspan="6">
637
+ <div class="stream-panel" id="streamPanel-${eid}" data-session-id="${escapeHtml(sessionId)}" data-cwd="${escapeHtml(jobCwd)}">
638
+ <div class="stream-content" id="streamContent-${eid}">
639
+ <div class="stream-empty">${escapeHtml(t('stream_loading'))}</div>
640
+ </div>
641
+ ${job.status === 'done' ? `<div class="stream-done-banner">✓ ${escapeHtml(t('stream_job_done'))}</div>` : ''}
642
+ ${job.status === 'failed' ? `<div class="stream-done-banner failed">✗ ${escapeHtml(t('stream_job_failed'))}</div>` : ''}
643
+ ${actionsHtml}
644
+ ${followupHtml}
645
+ <div id="ckptPanel-${eid}" class="ckpt-panel" style="display:none"></div>
646
+ </div>
647
+ </td>`;
648
+ }
649
+
650
+ function quickForkSession(sessionId, cwd) {
651
+ _contextMode = 'fork';
652
+ _contextSessionId = sessionId;
653
+ _contextSessionPrompt = null;
654
+ _updateContextUI();
655
+ if (cwd) {
656
+ addRecentDir(cwd);
657
+ selectRecentDir(cwd, true);
658
+ }
659
+ showToast(t('msg_fork_mode') + ' (' + sessionId.slice(0, 8) + '...). ' + t('msg_fork_input'));
660
+ document.getElementById('promptInput').focus();
661
+ window.scrollTo({ top: 0, behavior: 'smooth' });
662
+ }
663
+
664
+ function resumeFromJob(sessionId, promptHint, cwd) {
665
+ _contextMode = 'resume';
666
+ _contextSessionId = sessionId;
667
+ _contextSessionPrompt = promptHint || null;
668
+ _updateContextUI();
669
+ if (cwd) {
670
+ addRecentDir(cwd);
671
+ selectRecentDir(cwd, true);
672
+ }
673
+ showToast(t('msg_resume_mode').replace('{sid}', sessionId.slice(0, 8) + '...'));
674
+ document.getElementById('promptInput').focus();
675
+ window.scrollTo({ top: 0, behavior: 'smooth' });
676
+ }
677
+
678
+ function openFollowUp(jobId) {
679
+ if (expandedJobId !== jobId) {
680
+ toggleJobExpand(jobId);
681
+ setTimeout(() => focusFollowUpInput(jobId), 200);
682
+ } else {
683
+ focusFollowUpInput(jobId);
684
+ }
685
+ }
686
+
687
+ function focusFollowUpInput(jobId) {
688
+ const input = document.getElementById(`followupInput-${jobId}`);
689
+ if (input) {
690
+ input.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
691
+ input.focus();
692
+ }
693
+ }
694
+
695
+ const followUpAttachments = {};
696
+
697
+ async function handleFollowUpFiles(jobId, files) {
698
+ if (!followUpAttachments[jobId]) followUpAttachments[jobId] = [];
699
+ const container = document.getElementById(`followupPreviews-${jobId}`);
700
+ for (const file of files) {
701
+ try {
702
+ const data = await uploadFile(file);
703
+ followUpAttachments[jobId].push({ serverPath: data.path, filename: data.filename || file.name });
704
+ if (container) {
705
+ const chip = document.createElement('span');
706
+ chip.className = 'followup-file-chip';
707
+ chip.textContent = data.filename || file.name;
708
+ chip.title = data.path;
709
+ container.appendChild(chip);
710
+ }
711
+ const input = document.getElementById(`followupInput-${jobId}`);
712
+ if (input) {
713
+ const space = input.value.length > 0 && !input.value.endsWith(' ') ? ' ' : '';
714
+ input.value += space + '@' + data.path + ' ';
715
+ input.focus();
716
+ }
717
+ } catch (err) {
718
+ showToast(`${t('msg_upload_failed')}: ${file.name}`, 'error');
719
+ }
720
+ }
721
+ }
722
+
723
+ async function sendFollowUp(jobId) {
724
+ if (_sendLock) return;
725
+
726
+ const input = document.getElementById(`followupInput-${jobId}`);
727
+ if (!input) return;
728
+ const prompt = input.value.trim();
729
+ if (!prompt) {
730
+ showToast(t('msg_continue_input'), 'error');
731
+ return;
732
+ }
733
+
734
+ const panel = document.getElementById(`streamPanel-${jobId}`);
735
+ const sessionId = panel ? panel.dataset.sessionId : '';
736
+ const cwd = panel ? panel.dataset.cwd : '';
737
+
738
+ if (!sessionId) {
739
+ showToast(t('msg_no_session_id'), 'error');
740
+ return;
741
+ }
742
+
743
+ _sendLock = true;
744
+ const btn = input.parentElement.querySelector('.btn-primary');
745
+ const origHtml = btn.innerHTML;
746
+ btn.disabled = true;
747
+ btn.innerHTML = '<span class="spinner" style="width:12px;height:12px;"></span>';
748
+
749
+ try {
750
+ const images = (followUpAttachments[jobId] || []).map(a => a.serverPath);
751
+ const body = { prompt, session: `resume:${sessionId}` };
752
+ if (cwd) body.cwd = cwd;
753
+ if (images.length > 0) body.images = images;
754
+
755
+ await apiFetch('/api/send', { method: 'POST', body: JSON.stringify(body) });
756
+ showToast(t('msg_continue_sent'));
757
+ input.value = '';
758
+ delete followUpAttachments[jobId];
759
+ const container = document.getElementById(`followupPreviews-${jobId}`);
760
+ if (container) container.innerHTML = '';
761
+ fetchJobs();
762
+ } catch (err) {
763
+ showToast(`${t('msg_send_failed')}: ${err.message}`, 'error');
764
+ } finally {
765
+ _sendLock = false;
766
+ btn.disabled = false;
767
+ btn.innerHTML = origHtml;
768
+ }
769
+ }
770
+
771
+ async function retryJob(jobId) {
772
+ if (_sendLock) return;
773
+
774
+ const job = _allJobs.find(j => String(j.id || j.job_id) === String(jobId));
775
+ if (!job || !job.prompt) {
776
+ showToast(t('msg_no_original_prompt'), 'error');
777
+ return;
778
+ }
779
+
780
+ _sendLock = true;
781
+ try {
782
+ const body = { prompt: job.prompt };
783
+ if (job.cwd) body.cwd = job.cwd;
784
+
785
+ await apiFetch('/api/send', { method: 'POST', body: JSON.stringify(body) });
786
+ showToast(t('msg_rerun_done'));
787
+ fetchJobs();
788
+ } catch (err) {
789
+ showToast(`${t('msg_rerun_failed')}: ${err.message}`, 'error');
790
+ } finally {
791
+ _sendLock = false;
792
+ }
793
+ }
794
+
795
+ let _fetchJobsTimer = null;
796
+ let _fetchJobsInFlight = false;
797
+
798
+ async function _fetchJobsCore() {
799
+ if (_fetchJobsInFlight) return;
800
+ _fetchJobsInFlight = true;
801
+ try {
802
+ const params = new URLSearchParams({ page: _jobPage, limit: _jobLimit });
803
+ const data = await apiFetch(`/api/jobs?${params}`);
804
+ if (Array.isArray(data)) {
805
+ _jobTotal = data.length;
806
+ _jobPages = 1;
807
+ _jobPage = 1;
808
+ renderJobs(data);
809
+ } else {
810
+ _jobTotal = data.total || 0;
811
+ _jobPages = data.pages || 1;
812
+ _jobPage = data.page || 1;
813
+ renderJobs(data.jobs || []);
814
+ }
815
+ _renderJobPagination();
816
+ } catch {
817
+ // silent fail for polling
818
+ } finally {
819
+ _fetchJobsInFlight = false;
820
+ }
821
+ }
822
+
823
+ function fetchJobs() {
824
+ if (_fetchJobsTimer) clearTimeout(_fetchJobsTimer);
825
+ _fetchJobsTimer = setTimeout(_fetchJobsCore, 300);
826
+ }
827
+
828
+ function goJobPage(page) {
829
+ _jobPage = Math.max(1, Math.min(page, _jobPages));
830
+ fetchJobs();
831
+ }
832
+
833
+ function _renderJobPagination() {
834
+ let container = document.getElementById('jobPagination');
835
+ if (!container) return;
836
+ if (_jobPages <= 1) {
837
+ container.innerHTML = '';
838
+ return;
839
+ }
840
+ const prev = _jobPage > 1;
841
+ const next = _jobPage < _jobPages;
842
+ let html = '<div class="job-pagination">';
843
+ html += `<button class="pg-btn" ${prev ? '' : 'disabled'} onclick="goJobPage(${_jobPage - 1})">&laquo;</button>`;
844
+
845
+ const range = _buildPageRange(_jobPage, _jobPages);
846
+ for (const p of range) {
847
+ if (p === '...') {
848
+ html += '<span class="pg-ellipsis">…</span>';
849
+ } else {
850
+ html += `<button class="pg-btn${p === _jobPage ? ' active' : ''}" onclick="goJobPage(${p})">${p}</button>`;
851
+ }
852
+ }
853
+
854
+ html += `<button class="pg-btn" ${next ? '' : 'disabled'} onclick="goJobPage(${_jobPage + 1})">&raquo;</button>`;
855
+ html += `<span class="pg-info">${_jobPage}/${_jobPages} (${_jobTotal})</span>`;
856
+ html += '</div>';
857
+ container.innerHTML = html;
858
+ }
859
+
860
+ function _buildPageRange(current, total) {
861
+ if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
862
+ const pages = [];
863
+ pages.push(1);
864
+ if (current > 3) pages.push('...');
865
+ for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) {
866
+ pages.push(i);
867
+ }
868
+ if (current < total - 2) pages.push('...');
869
+ pages.push(total);
870
+ return pages;
871
+ }
872
+
873
+ function renderJobs(jobs) {
874
+ _allJobs = jobs;
875
+ _updateProjectDropdown(jobs);
876
+ _renderProjectStrip(jobs);
877
+ if (_selectedProject) _showProjectDetail();
878
+ const filtered = filterJobs(jobs);
879
+ const tbody = document.getElementById('jobTableBody');
880
+ const countEl = document.getElementById('jobCount');
881
+ const totalCount = _jobTotal || jobs.length;
882
+ const isFiltered = _jobFilterStatus !== 'all' || _jobFilterProject !== 'all' || _jobSearchQuery;
883
+ countEl.textContent = totalCount > 0
884
+ ? isFiltered ? `(${t('job_count_filtered').replace('{filtered}', filtered.length).replace('{total}', totalCount)})` : `(${t('job_count').replace('{n}', totalCount)})`
885
+ : '';
886
+
887
+ // 프로젝트 2개 이상 + 사용자가 수동 전환한 적 없으면 자동 grouped
888
+ if (!_jobViewModeManual) {
889
+ const projectCount = _extractProjects(jobs).length;
890
+ if (projectCount >= 2 && _jobViewMode !== 'grouped') {
891
+ _jobViewMode = 'grouped';
892
+ localStorage.setItem('jobViewMode', 'grouped');
893
+ } else if (projectCount < 2 && _jobViewMode === 'grouped') {
894
+ _jobViewMode = 'flat';
895
+ localStorage.setItem('jobViewMode', 'flat');
896
+ }
897
+ }
898
+
899
+ _updateViewModeUI();
900
+ const thead = tbody.closest('table')?.querySelector('thead');
901
+ if (_jobViewMode === 'grouped') {
902
+ if (thead) thead.style.display = 'none';
903
+ _renderGroupedView(filtered, tbody);
904
+ return;
905
+ }
906
+ if (thead) thead.style.display = '';
907
+
908
+ jobs = filtered;
909
+
910
+ if (jobs.length === 0) {
911
+ tbody.innerHTML = `<tr data-job-id="__empty__"><td colspan="6" class="empty-state">
912
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:40px;height:40px;margin-bottom:12px;opacity:0.3;"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>
913
+ <div>${t('no_jobs')}</div>
914
+ </td></tr>`;
915
+ return;
916
+ }
917
+
918
+ jobs.sort((a, b) => {
919
+ const aRunning = a.status === 'running' ? 0 : 1;
920
+ const bRunning = b.status === 'running' ? 0 : 1;
921
+ if (aRunning !== bRunning) return aRunning - bRunning;
922
+ return (parseInt(b.id || b.job_id || 0)) - (parseInt(a.id || a.job_id || 0));
923
+ });
924
+
925
+ for (const job of jobs) {
926
+ const id = job.id || job.job_id || '-';
927
+ if (streamState[id]) {
928
+ streamState[id].jobData = job;
929
+ }
930
+ }
931
+
932
+ const existingRows = {};
933
+ for (const row of tbody.querySelectorAll('tr[data-job-id]')) {
934
+ existingRows[row.dataset.jobId] = row;
935
+ }
936
+
937
+ const newIds = [];
938
+ for (const job of jobs) {
939
+ const id = job.id || job.job_id || '-';
940
+ newIds.push(id);
941
+ if (expandedJobId === id) newIds.push(id + '__expand');
942
+ }
943
+
944
+ const emptyRow = tbody.querySelector('tr[data-job-id="__empty__"]');
945
+ if (emptyRow) emptyRow.remove();
946
+
947
+ for (const job of jobs) {
948
+ const id = job.id || job.job_id || '-';
949
+ const isExpanded = expandedJobId === id;
950
+ const existing = existingRows[id];
951
+
952
+ if (existing && !existing.classList.contains('expand-row')) {
953
+ const cells = existing.querySelectorAll('td');
954
+ if (cells.length >= 6) {
955
+ const newStatus = statusBadgeHtml(job.status, id, job);
956
+ if (cells[1].innerHTML !== newStatus) cells[1].innerHTML = newStatus;
957
+ const newCwd = escapeHtml(formatCwd(job.cwd));
958
+ if (cells[3].innerHTML !== newCwd) {
959
+ cells[3].innerHTML = newCwd;
960
+ cells[3].title = job.cwd || '';
961
+ }
962
+ const newSession = job.session_id ? job.session_id.slice(0, 8) : (job.status === 'running' ? '—' : '-');
963
+ if (cells[4].textContent !== newSession) {
964
+ cells[4].textContent = newSession;
965
+ if (job.session_id) {
966
+ cells[4].className = 'job-session clickable';
967
+ cells[4].title = job.session_id;
968
+ cells[4].setAttribute('onclick', `event.stopPropagation(); resumeFromJob('${escapeJsStr(job.session_id)}', '${escapeJsStr(truncate(job.prompt, 40))}', '${escapeJsStr(job.cwd || '')}')`);
969
+ }
970
+ }
971
+ const newActions = jobActionsHtml(id, job.status, job.session_id, job.cwd);
972
+ if (cells[5].innerHTML !== newActions) {
973
+ cells[5].innerHTML = newActions;
974
+ }
975
+ }
976
+ existing.className = isExpanded ? 'expanded' : '';
977
+ delete existingRows[id];
978
+ } else if (!existing) {
979
+ const tr = document.createElement('tr');
980
+ tr.dataset.jobId = id;
981
+ tr.className = isExpanded ? 'expanded' : '';
982
+ tr.setAttribute('onclick', `toggleJobExpand('${escapeHtml(id)}')`);
983
+ tr.innerHTML = _buildJobRowCells(id, job);
984
+ tbody.appendChild(tr);
985
+ } else {
986
+ delete existingRows[id];
987
+ }
988
+
989
+ const expandKey = id + '__expand';
990
+ const existingExpand = existingRows[expandKey] || tbody.querySelector(`tr[data-job-id="${CSS.escape(expandKey)}"]`);
991
+
992
+ if (isExpanded) {
993
+ if (!existingExpand) {
994
+ const expandTr = document.createElement('tr');
995
+ expandTr.className = 'expand-row';
996
+ expandTr.dataset.jobId = expandKey;
997
+ expandTr.innerHTML = _buildExpandRowHtml(id, job);
998
+ const jobRow = tbody.querySelector(`tr[data-job-id="${CSS.escape(id)}"]`);
999
+ if (jobRow && jobRow.nextSibling) {
1000
+ tbody.insertBefore(expandTr, jobRow.nextSibling);
1001
+ } else {
1002
+ tbody.appendChild(expandTr);
1003
+ }
1004
+ initStream(id, job);
1005
+ } else {
1006
+ delete existingRows[expandKey];
1007
+ }
1008
+ } else if (existingExpand) {
1009
+ existingExpand.remove();
1010
+ delete existingRows[expandKey];
1011
+ }
1012
+
1013
+ // 실행 중인 작업: 미리보기 행
1014
+ const previewKey = id + '__preview';
1015
+ const existingPreview = existingRows[previewKey] || tbody.querySelector(`tr[data-job-id="${CSS.escape(previewKey)}"]`);
1016
+
1017
+ if (job.status === 'running' && !isExpanded) {
1018
+ if (!streamState[id]) {
1019
+ streamState[id] = { offset: 0, timer: null, done: false, jobData: job, events: [], renderedCount: 0 };
1020
+ }
1021
+ if (!streamState[id].timer) {
1022
+ initStream(id, job);
1023
+ }
1024
+ if (!existingPreview) {
1025
+ const pvTr = document.createElement('tr');
1026
+ pvTr.className = 'preview-row';
1027
+ pvTr.dataset.jobId = previewKey;
1028
+ pvTr.innerHTML = `<td colspan="6"><div class="job-preview" id="jobPreview-${escapeHtml(id)}"><span class="preview-text">${escapeHtml(t('stream_preview_wait'))}</span></div></td>`;
1029
+ const jobRow = tbody.querySelector(`tr[data-job-id="${CSS.escape(id)}"]`);
1030
+ if (jobRow && jobRow.nextSibling) {
1031
+ tbody.insertBefore(pvTr, jobRow.nextSibling);
1032
+ } else {
1033
+ tbody.appendChild(pvTr);
1034
+ }
1035
+ newIds.splice(newIds.indexOf(id) + 1, 0, previewKey);
1036
+ } else {
1037
+ delete existingRows[previewKey];
1038
+ newIds.splice(newIds.indexOf(id) + 1, 0, previewKey);
1039
+ }
1040
+ updateJobPreview(id);
1041
+ } else {
1042
+ if (existingPreview) {
1043
+ existingPreview.remove();
1044
+ delete existingRows[previewKey];
1045
+ }
1046
+ }
1047
+ }
1048
+
1049
+ for (const [key, row] of Object.entries(existingRows)) {
1050
+ row.remove();
1051
+ }
1052
+
1053
+ const currentOrder = [...tbody.querySelectorAll('tr[data-job-id]')].map(r => r.dataset.jobId);
1054
+ if (JSON.stringify(currentOrder) !== JSON.stringify(newIds)) {
1055
+ for (const nid of newIds) {
1056
+ const row = tbody.querySelector(`tr[data-job-id="${CSS.escape(nid)}"]`);
1057
+ if (row) tbody.appendChild(row);
1058
+ }
1059
+ }
1060
+
1061
+ const hasCompleted = jobs.some(j => j.status === 'done' || j.status === 'failed');
1062
+ const deleteBtn = document.getElementById('btnDeleteCompleted');
1063
+ deleteBtn.style.display = hasCompleted ? 'inline-flex' : 'none';
1064
+ }
1065
+
1066
+ function updateJobRowStatus(jobId, status) {
1067
+ const tbody = document.getElementById('jobTableBody');
1068
+ const row = tbody.querySelector(`tr[data-job-id="${CSS.escape(jobId)}"]`);
1069
+ if (!row || row.classList.contains('expand-row')) return;
1070
+ const cells = row.querySelectorAll('td');
1071
+ if (cells.length >= 2) {
1072
+ const job = _allJobs.find(j => String(j.id || j.job_id) === String(jobId));
1073
+ const newBadge = statusBadgeHtml(status, jobId, job);
1074
+ if (cells[1].innerHTML !== newBadge) cells[1].innerHTML = newBadge;
1075
+ }
1076
+ }
1077
+
1078
+ function toggleJobExpand(id) {
1079
+ const tbody = document.getElementById('jobTableBody');
1080
+ if (expandedJobId === id) {
1081
+ stopStream(expandedJobId);
1082
+ const expandRow = tbody.querySelector(`tr[data-job-id="${CSS.escape(id + '__expand')}"]`);
1083
+ if (expandRow) expandRow.remove();
1084
+ const jobRow = tbody.querySelector(`tr[data-job-id="${CSS.escape(id)}"]`);
1085
+ if (jobRow) jobRow.className = '';
1086
+ expandedJobId = null;
1087
+ } else {
1088
+ if (expandedJobId) {
1089
+ stopStream(expandedJobId);
1090
+ const prevExpand = tbody.querySelector(`tr[data-job-id="${CSS.escape(expandedJobId + '__expand')}"]`);
1091
+ if (prevExpand) prevExpand.remove();
1092
+ const prevRow = tbody.querySelector(`tr[data-job-id="${CSS.escape(expandedJobId)}"]`);
1093
+ if (prevRow) prevRow.className = '';
1094
+ }
1095
+ expandedJobId = id;
1096
+ renderJobs(_allJobs);
1097
+ }
1098
+ }
1099
+
1100
+ async function deleteJob(jobId) {
1101
+ if (!confirm(t('confirm_delete_job'))) return;
1102
+ try {
1103
+ await apiFetch(`/api/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' });
1104
+ if (streamState[jobId]) {
1105
+ stopStream(jobId);
1106
+ delete streamState[jobId];
1107
+ }
1108
+ if (expandedJobId === jobId) expandedJobId = null;
1109
+ showToast(t('msg_job_deleted'));
1110
+ fetchJobs();
1111
+ } catch (err) {
1112
+ showToast(`${t('msg_delete_failed')}: ${err.message}`, 'error');
1113
+ }
1114
+ }
1115
+
1116
+ async function deleteCompletedJobs() {
1117
+ if (!confirm(t('confirm_delete_completed'))) return;
1118
+ try {
1119
+ const data = await apiFetch('/api/jobs', { method: 'DELETE' });
1120
+ const count = data.count || 0;
1121
+ for (const id of (data.deleted || [])) {
1122
+ if (streamState[id]) {
1123
+ stopStream(id);
1124
+ delete streamState[id];
1125
+ }
1126
+ if (expandedJobId === id) expandedJobId = null;
1127
+ }
1128
+ showToast(count + t('msg_batch_deleted'));
1129
+ fetchJobs();
1130
+ } catch (err) {
1131
+ showToast(`${t('msg_batch_delete_failed')}: ${err.message}`, 'error');
1132
+ }
1133
+ }
1134
+
1135
+ /* ═══════════════════════════════════════════════
1136
+ Checkpoint Diff Viewer
1137
+ ═══════════════════════════════════════════════ */
1138
+
1139
+ let _ckptCache = {}; // jobId → checkpoints array
1140
+
1141
+ async function toggleCheckpointPanel(jobId) {
1142
+ const panel = document.getElementById(`ckptPanel-${jobId}`);
1143
+ if (!panel) return;
1144
+
1145
+ if (panel.style.display !== 'none') {
1146
+ panel.style.display = 'none';
1147
+ return;
1148
+ }
1149
+ panel.style.display = '';
1150
+ panel.innerHTML = `<div class="ckpt-loading">${t('diff_loading')}</div>`;
1151
+
1152
+ try {
1153
+ const checkpoints = await apiFetch(`/api/jobs/${encodeURIComponent(jobId)}/checkpoints`);
1154
+ _ckptCache[jobId] = checkpoints;
1155
+ renderCheckpointSelector(jobId, checkpoints);
1156
+ } catch (err) {
1157
+ panel.innerHTML = `<div class="ckpt-empty">${t('diff_no_checkpoints')}</div>`;
1158
+ }
1159
+ }
1160
+
1161
+ function renderCheckpointSelector(jobId, checkpoints) {
1162
+ const panel = document.getElementById(`ckptPanel-${jobId}`);
1163
+ if (!panel) return;
1164
+
1165
+ if (!checkpoints || checkpoints.length === 0) {
1166
+ panel.innerHTML = `<div class="ckpt-empty">${t('diff_no_checkpoints')}</div>`;
1167
+ return;
1168
+ }
1169
+
1170
+ const optionsHtml = checkpoints.map((c, i) => {
1171
+ const label = `#${c.turn} — ${c.hash.slice(0, 7)} (${c.files_changed} ${t('diff_files')})`;
1172
+ return `<option value="${escapeHtml(c.hash)}"${i === 0 ? ' selected' : ''}>${escapeHtml(label)}</option>`;
1173
+ }).join('');
1174
+
1175
+ const prevDefault = checkpoints.length > 1 ? checkpoints[1].hash : '';
1176
+ const prevOptions = checkpoints.map((c, i) => {
1177
+ const label = `#${c.turn} — ${c.hash.slice(0, 7)}`;
1178
+ return `<option value="${escapeHtml(c.hash)}"${i === 1 ? ' selected' : ''}>${escapeHtml(label)}</option>`;
1179
+ }).join('');
1180
+
1181
+ panel.innerHTML = `
1182
+ <div class="ckpt-selector">
1183
+ <div class="ckpt-select-group">
1184
+ <label>From</label>
1185
+ <select id="ckptFrom-${escapeHtml(jobId)}" onclick="event.stopPropagation()">${prevOptions}</select>
1186
+ </div>
1187
+ <span class="ckpt-arrow">→</span>
1188
+ <div class="ckpt-select-group">
1189
+ <label>To</label>
1190
+ <select id="ckptTo-${escapeHtml(jobId)}" onclick="event.stopPropagation()">${optionsHtml}</select>
1191
+ </div>
1192
+ <button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); loadDiff('${escapeHtml(jobId)}')">${t('diff_compare')}</button>
1193
+ </div>
1194
+ <div class="ckpt-hint">${t('diff_select_hint')}</div>
1195
+ <div id="diffResult-${escapeHtml(jobId)}" class="diff-result"></div>`;
1196
+
1197
+ // 자동으로 첫 체크포인트의 단독 diff 로드
1198
+ if (checkpoints.length >= 1) {
1199
+ loadSingleDiff(jobId, checkpoints[0].hash);
1200
+ }
1201
+ }
1202
+
1203
+ async function loadSingleDiff(jobId, hash) {
1204
+ const container = document.getElementById(`diffResult-${jobId}`);
1205
+ if (!container) return;
1206
+ container.innerHTML = `<div class="ckpt-loading">${t('diff_loading')}</div>`;
1207
+
1208
+ try {
1209
+ const data = await apiFetch(`/api/jobs/${encodeURIComponent(jobId)}/diff?from=${encodeURIComponent(hash)}`);
1210
+ renderDiffResult(container, data);
1211
+ } catch (err) {
1212
+ container.innerHTML = `<div class="ckpt-empty">${err.message || t('diff_no_changes')}</div>`;
1213
+ }
1214
+ }
1215
+
1216
+ async function loadDiff(jobId) {
1217
+ const fromEl = document.getElementById(`ckptFrom-${jobId}`);
1218
+ const toEl = document.getElementById(`ckptTo-${jobId}`);
1219
+ if (!fromEl || !toEl) return;
1220
+
1221
+ const container = document.getElementById(`diffResult-${jobId}`);
1222
+ if (!container) return;
1223
+ container.innerHTML = `<div class="ckpt-loading">${t('diff_loading')}</div>`;
1224
+
1225
+ try {
1226
+ const data = await apiFetch(
1227
+ `/api/jobs/${encodeURIComponent(jobId)}/diff?from=${encodeURIComponent(fromEl.value)}&to=${encodeURIComponent(toEl.value)}`
1228
+ );
1229
+ renderDiffResult(container, data);
1230
+ } catch (err) {
1231
+ container.innerHTML = `<div class="ckpt-empty">${err.message || t('diff_no_changes')}</div>`;
1232
+ }
1233
+ }
1234
+
1235
+ function renderDiffResult(container, data) {
1236
+ if (!data.files || data.files.length === 0) {
1237
+ container.innerHTML = `<div class="ckpt-empty">${t('diff_no_changes')}</div>`;
1238
+ return;
1239
+ }
1240
+
1241
+ const summary = `<div class="diff-summary">
1242
+ <span class="diff-stat-files">${data.total_files} ${t('diff_files')}</span>
1243
+ <span class="diff-stat-add">+${data.total_additions} ${t('diff_additions')}</span>
1244
+ <span class="diff-stat-del">-${data.total_deletions} ${t('diff_deletions')}</span>
1245
+ </div>`;
1246
+
1247
+ const filesHtml = data.files.map((f, idx) => {
1248
+ const lines = f.chunks.map(line => {
1249
+ const escaped = escapeHtml(line);
1250
+ if (line.startsWith('@@')) return `<div class="diff-line diff-hunk">${escaped}</div>`;
1251
+ if (line.startsWith('+')) return `<div class="diff-line diff-add">${escaped}</div>`;
1252
+ if (line.startsWith('-')) return `<div class="diff-line diff-del">${escaped}</div>`;
1253
+ if (line.startsWith('\\')) return `<div class="diff-line diff-meta">${escaped}</div>`;
1254
+ return `<div class="diff-line">${escaped}</div>`;
1255
+ }).join('');
1256
+
1257
+ return `<div class="diff-file">
1258
+ <div class="diff-file-header" onclick="event.stopPropagation(); this.parentElement.classList.toggle('collapsed')">
1259
+ <span class="diff-file-name">${escapeHtml(f.file)}</span>
1260
+ <span class="diff-file-stats">
1261
+ <span class="diff-stat-add">+${f.additions}</span>
1262
+ <span class="diff-stat-del">-${f.deletions}</span>
1263
+ </span>
1264
+ </div>
1265
+ <div class="diff-file-body"><pre class="diff-code">${lines}</pre></div>
1266
+ </div>`;
1267
+ }).join('');
1268
+
1269
+ container.innerHTML = summary + filesHtml;
1270
+ }