claude-controller 0.2.0 → 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 (68) hide show
  1. package/README.md +2 -2
  2. package/bin/autoloop.sh +382 -0
  3. package/bin/ctl +327 -5
  4. package/bin/native-app.py +5 -2
  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 +5 -1
  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 +464 -26
  38. package/web/handler_fs.py +15 -14
  39. package/web/handler_goals.py +203 -0
  40. package/web/handler_jobs.py +165 -42
  41. package/web/handler_memory.py +203 -0
  42. package/web/jobs.py +576 -12
  43. package/web/personas.py +419 -0
  44. package/web/pipeline.py +682 -50
  45. package/web/presets.py +506 -0
  46. package/web/projects.py +58 -4
  47. package/web/static/api.js +90 -3
  48. package/web/static/app.js +8 -0
  49. package/web/static/base.css +51 -12
  50. package/web/static/context.js +14 -4
  51. package/web/static/form.css +3 -2
  52. package/web/static/goals.css +363 -0
  53. package/web/static/goals.js +300 -0
  54. package/web/static/i18n.js +288 -0
  55. package/web/static/index.html +142 -6
  56. package/web/static/jobs.css +951 -4
  57. package/web/static/jobs.js +890 -54
  58. package/web/static/memoryview.js +117 -0
  59. package/web/static/personas.js +228 -0
  60. package/web/static/pipeline.css +308 -1
  61. package/web/static/pipelines.js +249 -14
  62. package/web/static/presets.js +244 -0
  63. package/web/static/send.js +26 -4
  64. package/web/static/settings-style.css +34 -3
  65. package/web/static/settings.js +37 -1
  66. package/web/static/stream.js +242 -19
  67. package/web/static/utils.js +54 -2
  68. package/web/webhook.py +210 -0
@@ -4,32 +4,649 @@
4
4
 
5
5
  let expandedJobId = null;
6
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;
7
23
 
8
- function statusBadgeHtml(status) {
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) {
9
553
  const s = (status || 'unknown').toLowerCase();
10
554
  const labels = { running: t('status_running'), done: t('status_done'), failed: t('status_failed'), pending: t('status_pending') };
11
555
  const cls = { running: 'badge-running', done: 'badge-done', failed: 'badge-failed', pending: 'badge-pending' };
12
- return `<span class="badge ${cls[s] || 'badge-pending'}">${labels[s] || s}</span>`;
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;
13
578
  }
14
579
 
15
580
  function jobActionsHtml(id, status, sessionId, cwd) {
16
581
  const isRunning = status === 'running';
17
- const escapedCwd = escapeHtml(cwd || '');
582
+ const escapedCwd = escapeHtml(escapeJsStr(cwd || ''));
18
583
  let btns = '';
19
584
  if (!isRunning) {
20
- btns += `<button class="btn-retry-job" onclick="event.stopPropagation(); retryJob('${escapeHtml(id)}')" 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>`;
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>`;
21
586
  }
22
587
  if (sessionId) {
23
- btns += `<button class="btn-continue-job" onclick="event.stopPropagation(); openFollowUp('${escapeHtml(id)}')" title="세션 이어서 명령 (resume)"><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>`;
24
- btns += `<button class="btn-fork-job" onclick="event.stopPropagation(); quickForkSession('${escapeHtml(sessionId)}', '${escapedCwd}')" title="이 세션에서 분기 (fork)" 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>`;
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>`;
25
590
  }
26
591
  if (!isRunning) {
27
- btns += `<button class="btn-delete-job" onclick="event.stopPropagation(); deleteJob('${escapeHtml(id)}')" 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>`;
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>`;
28
593
  }
29
594
  if (!btns) return '';
30
595
  return `<div style="display:flex; align-items:center; gap:4px;">${btns}</div>`;
31
596
  }
32
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
+
33
650
  function quickForkSession(sessionId, cwd) {
34
651
  _contextMode = 'fork';
35
652
  _contextSessionId = sessionId;
@@ -53,7 +670,7 @@ function resumeFromJob(sessionId, promptHint, cwd) {
53
670
  addRecentDir(cwd);
54
671
  selectRecentDir(cwd, true);
55
672
  }
56
- showToast('Resume 모드: ' + sessionId.slice(0, 8) + '... 세션에 이어서 전송합니다.');
673
+ showToast(t('msg_resume_mode').replace('{sid}', sessionId.slice(0, 8) + '...'));
57
674
  document.getElementById('promptInput').focus();
58
675
  window.scrollTo({ top: 0, behavior: 'smooth' });
59
676
  }
@@ -153,17 +770,15 @@ async function sendFollowUp(jobId) {
153
770
 
154
771
  async function retryJob(jobId) {
155
772
  if (_sendLock) return;
156
- _sendLock = true;
157
773
 
158
- try {
159
- const data = await apiFetch('/api/jobs');
160
- const jobs = Array.isArray(data) ? data : (data.jobs || []);
161
- const job = jobs.find(j => String(j.id || j.job_id) === String(jobId));
162
- if (!job || !job.prompt) {
163
- showToast(t('msg_no_original_prompt'), 'error');
164
- return;
165
- }
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
+ }
166
779
 
780
+ _sendLock = true;
781
+ try {
167
782
  const body = { prompt: job.prompt };
168
783
  if (job.cwd) body.cwd = job.cwd;
169
784
 
@@ -177,23 +792,123 @@ async function retryJob(jobId) {
177
792
  }
178
793
  }
179
794
 
180
- async function fetchJobs() {
795
+ let _fetchJobsTimer = null;
796
+ let _fetchJobsInFlight = false;
797
+
798
+ async function _fetchJobsCore() {
799
+ if (_fetchJobsInFlight) return;
800
+ _fetchJobsInFlight = true;
181
801
  try {
182
- const data = await apiFetch('/api/jobs');
183
- const jobs = Array.isArray(data) ? data : (data.jobs || []);
184
- renderJobs(jobs);
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();
185
816
  } catch {
186
817
  // silent fail for polling
818
+ } finally {
819
+ _fetchJobsInFlight = false;
187
820
  }
188
821
  }
189
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
+
190
873
  function renderJobs(jobs) {
874
+ _allJobs = jobs;
875
+ _updateProjectDropdown(jobs);
876
+ _renderProjectStrip(jobs);
877
+ if (_selectedProject) _showProjectDetail();
878
+ const filtered = filterJobs(jobs);
191
879
  const tbody = document.getElementById('jobTableBody');
192
880
  const countEl = document.getElementById('jobCount');
193
- countEl.textContent = jobs.length > 0 ? `(${jobs.length}건)` : '';
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;
194
909
 
195
910
  if (jobs.length === 0) {
196
- tbody.innerHTML = `<tr data-job-id="__empty__"><td colspan="7" class="empty-state">
911
+ tbody.innerHTML = `<tr data-job-id="__empty__"><td colspan="6" class="empty-state">
197
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>
198
913
  <div>${t('no_jobs')}</div>
199
914
  </td></tr>`;
@@ -236,8 +951,8 @@ function renderJobs(jobs) {
236
951
 
237
952
  if (existing && !existing.classList.contains('expand-row')) {
238
953
  const cells = existing.querySelectorAll('td');
239
- if (cells.length >= 7) {
240
- const newStatus = statusBadgeHtml(job.status);
954
+ if (cells.length >= 6) {
955
+ const newStatus = statusBadgeHtml(job.status, id, job);
241
956
  if (cells[1].innerHTML !== newStatus) cells[1].innerHTML = newStatus;
242
957
  const newCwd = escapeHtml(formatCwd(job.cwd));
243
958
  if (cells[3].innerHTML !== newCwd) {
@@ -250,12 +965,12 @@ function renderJobs(jobs) {
250
965
  if (job.session_id) {
251
966
  cells[4].className = 'job-session clickable';
252
967
  cells[4].title = job.session_id;
253
- cells[4].setAttribute('onclick', `event.stopPropagation(); resumeFromJob('${escapeHtml(job.session_id)}', '${escapeHtml(truncate(job.prompt, 40))}', '${escapeHtml(job.cwd || '')}')`);
968
+ cells[4].setAttribute('onclick', `event.stopPropagation(); resumeFromJob('${escapeJsStr(job.session_id)}', '${escapeJsStr(truncate(job.prompt, 40))}', '${escapeJsStr(job.cwd || '')}')`);
254
969
  }
255
970
  }
256
971
  const newActions = jobActionsHtml(id, job.status, job.session_id, job.cwd);
257
- if (cells[6].innerHTML !== newActions) {
258
- cells[6].innerHTML = newActions;
972
+ if (cells[5].innerHTML !== newActions) {
973
+ cells[5].innerHTML = newActions;
259
974
  }
260
975
  }
261
976
  existing.className = isExpanded ? 'expanded' : '';
@@ -265,14 +980,7 @@ function renderJobs(jobs) {
265
980
  tr.dataset.jobId = id;
266
981
  tr.className = isExpanded ? 'expanded' : '';
267
982
  tr.setAttribute('onclick', `toggleJobExpand('${escapeHtml(id)}')`);
268
- tr.innerHTML = `
269
- <td class="job-id">${escapeHtml(String(id).slice(0, 8))}</td>
270
- <td>${statusBadgeHtml(job.status)}</td>
271
- <td class="prompt-cell" title="${escapeHtml(job.prompt)}">${renderPromptHtml(job.prompt)}</td>
272
- <td class="job-cwd" title="${escapeHtml(job.cwd || '')}">${escapeHtml(formatCwd(job.cwd))}</td>
273
- <td class="job-session${job.session_id ? ' clickable' : ''}" title="${escapeHtml(job.session_id || '')}" ${job.session_id ? `onclick="event.stopPropagation(); resumeFromJob('${escapeHtml(job.session_id)}', '${escapeHtml(truncate(job.prompt, 40))}', '${escapeHtml(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>
274
- <td class="job-time">${formatTime(job.created || job.created_at)}</td>
275
- <td>${jobActionsHtml(id, job.status, job.session_id, job.cwd)}</td>`;
983
+ tr.innerHTML = _buildJobRowCells(id, job);
276
984
  tbody.appendChild(tr);
277
985
  } else {
278
986
  delete existingRows[id];
@@ -286,26 +994,14 @@ function renderJobs(jobs) {
286
994
  const expandTr = document.createElement('tr');
287
995
  expandTr.className = 'expand-row';
288
996
  expandTr.dataset.jobId = expandKey;
289
- const sessionId = job.session_id || '';
290
- const jobCwd = job.cwd || '';
291
- expandTr.innerHTML = `<td colspan="7">
292
- <div class="stream-panel" id="streamPanel-${escapeHtml(id)}" data-session-id="${escapeHtml(sessionId)}" data-cwd="${escapeHtml(jobCwd)}">
293
- <div class="stream-content" id="streamContent-${escapeHtml(id)}">
294
- <div class="stream-empty">스트림 데이터를 불러오는 중...</div>
295
- </div>
296
- ${job.status === 'done' ? '<div class="stream-done-banner">✓ 작업 완료</div>' : ''}
297
- ${job.status === 'failed' ? '<div class="stream-done-banner failed">✗ 작업 실패</div>' : ''}
298
- ${job.status !== 'running' ? `<div class="stream-actions"><button class="btn btn-sm" onclick="event.stopPropagation(); retryJob('${escapeHtml(id)}')"><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><button class="btn btn-sm" onclick="event.stopPropagation(); copyStreamResult('${escapeHtml(id)}')"><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> 전체 복사</button><button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); deleteJob('${escapeHtml(id)}')"><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></div>` : ''}
299
- ${(job.status !== 'running' && sessionId) ? `<div class="stream-followup"><span class="stream-followup-label">이어서</span><div class="followup-input-wrap"><input type="text" class="followup-input" id="followupInput-${escapeHtml(id)}" placeholder="이 세션에 이어서 실행할 명령..." onkeydown="if(event.key==='Enter'){event.stopPropagation();sendFollowUp('${escapeHtml(id)}')}" onclick="event.stopPropagation()"><button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); sendFollowUp('${escapeHtml(id)}')" 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> 전송</button></div></div>` : ''}
300
- </div>
301
- </td>`;
997
+ expandTr.innerHTML = _buildExpandRowHtml(id, job);
302
998
  const jobRow = tbody.querySelector(`tr[data-job-id="${CSS.escape(id)}"]`);
303
999
  if (jobRow && jobRow.nextSibling) {
304
1000
  tbody.insertBefore(expandTr, jobRow.nextSibling);
305
1001
  } else {
306
1002
  tbody.appendChild(expandTr);
307
1003
  }
308
- initStream(id);
1004
+ initStream(id, job);
309
1005
  } else {
310
1006
  delete existingRows[expandKey];
311
1007
  }
@@ -323,13 +1019,13 @@ function renderJobs(jobs) {
323
1019
  streamState[id] = { offset: 0, timer: null, done: false, jobData: job, events: [], renderedCount: 0 };
324
1020
  }
325
1021
  if (!streamState[id].timer) {
326
- initStream(id);
1022
+ initStream(id, job);
327
1023
  }
328
1024
  if (!existingPreview) {
329
1025
  const pvTr = document.createElement('tr');
330
1026
  pvTr.className = 'preview-row';
331
1027
  pvTr.dataset.jobId = previewKey;
332
- pvTr.innerHTML = `<td colspan="7"><div class="job-preview" id="jobPreview-${escapeHtml(id)}"><span class="preview-text">대기 중...</span></div></td>`;
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>`;
333
1029
  const jobRow = tbody.querySelector(`tr[data-job-id="${CSS.escape(id)}"]`);
334
1030
  if (jobRow && jobRow.nextSibling) {
335
1031
  tbody.insertBefore(pvTr, jobRow.nextSibling);
@@ -373,7 +1069,8 @@ function updateJobRowStatus(jobId, status) {
373
1069
  if (!row || row.classList.contains('expand-row')) return;
374
1070
  const cells = row.querySelectorAll('td');
375
1071
  if (cells.length >= 2) {
376
- const newBadge = statusBadgeHtml(status);
1072
+ const job = _allJobs.find(j => String(j.id || j.job_id) === String(jobId));
1073
+ const newBadge = statusBadgeHtml(status, jobId, job);
377
1074
  if (cells[1].innerHTML !== newBadge) cells[1].innerHTML = newBadge;
378
1075
  }
379
1076
  }
@@ -396,11 +1093,12 @@ function toggleJobExpand(id) {
396
1093
  if (prevRow) prevRow.className = '';
397
1094
  }
398
1095
  expandedJobId = id;
399
- fetchJobs();
1096
+ renderJobs(_allJobs);
400
1097
  }
401
1098
  }
402
1099
 
403
1100
  async function deleteJob(jobId) {
1101
+ if (!confirm(t('confirm_delete_job'))) return;
404
1102
  try {
405
1103
  await apiFetch(`/api/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' });
406
1104
  if (streamState[jobId]) {
@@ -416,6 +1114,7 @@ async function deleteJob(jobId) {
416
1114
  }
417
1115
 
418
1116
  async function deleteCompletedJobs() {
1117
+ if (!confirm(t('confirm_delete_completed'))) return;
419
1118
  try {
420
1119
  const data = await apiFetch('/api/jobs', { method: 'DELETE' });
421
1120
  const count = data.count || 0;
@@ -432,3 +1131,140 @@ async function deleteCompletedJobs() {
432
1131
  showToast(`${t('msg_batch_delete_failed')}: ${err.message}`, 'error');
433
1132
  }
434
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
+ }