claude-controller 0.1.2 → 0.2.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.
@@ -0,0 +1,434 @@
1
+ /* ═══════════════════════════════════════════════
2
+ Jobs — 작업 목록 렌더링, CRUD, 후속 명령
3
+ ═══════════════════════════════════════════════ */
4
+
5
+ let expandedJobId = null;
6
+ let jobPollTimer = null;
7
+
8
+ function statusBadgeHtml(status) {
9
+ const s = (status || 'unknown').toLowerCase();
10
+ const labels = { running: t('status_running'), done: t('status_done'), failed: t('status_failed'), pending: t('status_pending') };
11
+ 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>`;
13
+ }
14
+
15
+ function jobActionsHtml(id, status, sessionId, cwd) {
16
+ const isRunning = status === 'running';
17
+ const escapedCwd = escapeHtml(cwd || '');
18
+ let btns = '';
19
+ 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>`;
21
+ }
22
+ 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>`;
25
+ }
26
+ 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>`;
28
+ }
29
+ if (!btns) return '';
30
+ return `<div style="display:flex; align-items:center; gap:4px;">${btns}</div>`;
31
+ }
32
+
33
+ function quickForkSession(sessionId, cwd) {
34
+ _contextMode = 'fork';
35
+ _contextSessionId = sessionId;
36
+ _contextSessionPrompt = null;
37
+ _updateContextUI();
38
+ if (cwd) {
39
+ addRecentDir(cwd);
40
+ selectRecentDir(cwd, true);
41
+ }
42
+ showToast(t('msg_fork_mode') + ' (' + sessionId.slice(0, 8) + '...). ' + t('msg_fork_input'));
43
+ document.getElementById('promptInput').focus();
44
+ window.scrollTo({ top: 0, behavior: 'smooth' });
45
+ }
46
+
47
+ function resumeFromJob(sessionId, promptHint, cwd) {
48
+ _contextMode = 'resume';
49
+ _contextSessionId = sessionId;
50
+ _contextSessionPrompt = promptHint || null;
51
+ _updateContextUI();
52
+ if (cwd) {
53
+ addRecentDir(cwd);
54
+ selectRecentDir(cwd, true);
55
+ }
56
+ showToast('Resume 모드: ' + sessionId.slice(0, 8) + '... 세션에 이어서 전송합니다.');
57
+ document.getElementById('promptInput').focus();
58
+ window.scrollTo({ top: 0, behavior: 'smooth' });
59
+ }
60
+
61
+ function openFollowUp(jobId) {
62
+ if (expandedJobId !== jobId) {
63
+ toggleJobExpand(jobId);
64
+ setTimeout(() => focusFollowUpInput(jobId), 200);
65
+ } else {
66
+ focusFollowUpInput(jobId);
67
+ }
68
+ }
69
+
70
+ function focusFollowUpInput(jobId) {
71
+ const input = document.getElementById(`followupInput-${jobId}`);
72
+ if (input) {
73
+ input.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
74
+ input.focus();
75
+ }
76
+ }
77
+
78
+ const followUpAttachments = {};
79
+
80
+ async function handleFollowUpFiles(jobId, files) {
81
+ if (!followUpAttachments[jobId]) followUpAttachments[jobId] = [];
82
+ const container = document.getElementById(`followupPreviews-${jobId}`);
83
+ for (const file of files) {
84
+ try {
85
+ const data = await uploadFile(file);
86
+ followUpAttachments[jobId].push({ serverPath: data.path, filename: data.filename || file.name });
87
+ if (container) {
88
+ const chip = document.createElement('span');
89
+ chip.className = 'followup-file-chip';
90
+ chip.textContent = data.filename || file.name;
91
+ chip.title = data.path;
92
+ container.appendChild(chip);
93
+ }
94
+ const input = document.getElementById(`followupInput-${jobId}`);
95
+ if (input) {
96
+ const space = input.value.length > 0 && !input.value.endsWith(' ') ? ' ' : '';
97
+ input.value += space + '@' + data.path + ' ';
98
+ input.focus();
99
+ }
100
+ } catch (err) {
101
+ showToast(`${t('msg_upload_failed')}: ${file.name}`, 'error');
102
+ }
103
+ }
104
+ }
105
+
106
+ async function sendFollowUp(jobId) {
107
+ if (_sendLock) return;
108
+
109
+ const input = document.getElementById(`followupInput-${jobId}`);
110
+ if (!input) return;
111
+ const prompt = input.value.trim();
112
+ if (!prompt) {
113
+ showToast(t('msg_continue_input'), 'error');
114
+ return;
115
+ }
116
+
117
+ const panel = document.getElementById(`streamPanel-${jobId}`);
118
+ const sessionId = panel ? panel.dataset.sessionId : '';
119
+ const cwd = panel ? panel.dataset.cwd : '';
120
+
121
+ if (!sessionId) {
122
+ showToast(t('msg_no_session_id'), 'error');
123
+ return;
124
+ }
125
+
126
+ _sendLock = true;
127
+ const btn = input.parentElement.querySelector('.btn-primary');
128
+ const origHtml = btn.innerHTML;
129
+ btn.disabled = true;
130
+ btn.innerHTML = '<span class="spinner" style="width:12px;height:12px;"></span>';
131
+
132
+ try {
133
+ const images = (followUpAttachments[jobId] || []).map(a => a.serverPath);
134
+ const body = { prompt, session: `resume:${sessionId}` };
135
+ if (cwd) body.cwd = cwd;
136
+ if (images.length > 0) body.images = images;
137
+
138
+ await apiFetch('/api/send', { method: 'POST', body: JSON.stringify(body) });
139
+ showToast(t('msg_continue_sent'));
140
+ input.value = '';
141
+ delete followUpAttachments[jobId];
142
+ const container = document.getElementById(`followupPreviews-${jobId}`);
143
+ if (container) container.innerHTML = '';
144
+ fetchJobs();
145
+ } catch (err) {
146
+ showToast(`${t('msg_send_failed')}: ${err.message}`, 'error');
147
+ } finally {
148
+ _sendLock = false;
149
+ btn.disabled = false;
150
+ btn.innerHTML = origHtml;
151
+ }
152
+ }
153
+
154
+ async function retryJob(jobId) {
155
+ if (_sendLock) return;
156
+ _sendLock = true;
157
+
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
+ }
166
+
167
+ const body = { prompt: job.prompt };
168
+ if (job.cwd) body.cwd = job.cwd;
169
+
170
+ await apiFetch('/api/send', { method: 'POST', body: JSON.stringify(body) });
171
+ showToast(t('msg_rerun_done'));
172
+ fetchJobs();
173
+ } catch (err) {
174
+ showToast(`${t('msg_rerun_failed')}: ${err.message}`, 'error');
175
+ } finally {
176
+ _sendLock = false;
177
+ }
178
+ }
179
+
180
+ async function fetchJobs() {
181
+ try {
182
+ const data = await apiFetch('/api/jobs');
183
+ const jobs = Array.isArray(data) ? data : (data.jobs || []);
184
+ renderJobs(jobs);
185
+ } catch {
186
+ // silent fail for polling
187
+ }
188
+ }
189
+
190
+ function renderJobs(jobs) {
191
+ const tbody = document.getElementById('jobTableBody');
192
+ const countEl = document.getElementById('jobCount');
193
+ countEl.textContent = jobs.length > 0 ? `(${jobs.length}건)` : '';
194
+
195
+ if (jobs.length === 0) {
196
+ tbody.innerHTML = `<tr data-job-id="__empty__"><td colspan="7" class="empty-state">
197
+ <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
+ <div>${t('no_jobs')}</div>
199
+ </td></tr>`;
200
+ return;
201
+ }
202
+
203
+ jobs.sort((a, b) => {
204
+ const aRunning = a.status === 'running' ? 0 : 1;
205
+ const bRunning = b.status === 'running' ? 0 : 1;
206
+ if (aRunning !== bRunning) return aRunning - bRunning;
207
+ return (parseInt(b.id || b.job_id || 0)) - (parseInt(a.id || a.job_id || 0));
208
+ });
209
+
210
+ for (const job of jobs) {
211
+ const id = job.id || job.job_id || '-';
212
+ if (streamState[id]) {
213
+ streamState[id].jobData = job;
214
+ }
215
+ }
216
+
217
+ const existingRows = {};
218
+ for (const row of tbody.querySelectorAll('tr[data-job-id]')) {
219
+ existingRows[row.dataset.jobId] = row;
220
+ }
221
+
222
+ const newIds = [];
223
+ for (const job of jobs) {
224
+ const id = job.id || job.job_id || '-';
225
+ newIds.push(id);
226
+ if (expandedJobId === id) newIds.push(id + '__expand');
227
+ }
228
+
229
+ const emptyRow = tbody.querySelector('tr[data-job-id="__empty__"]');
230
+ if (emptyRow) emptyRow.remove();
231
+
232
+ for (const job of jobs) {
233
+ const id = job.id || job.job_id || '-';
234
+ const isExpanded = expandedJobId === id;
235
+ const existing = existingRows[id];
236
+
237
+ if (existing && !existing.classList.contains('expand-row')) {
238
+ const cells = existing.querySelectorAll('td');
239
+ if (cells.length >= 7) {
240
+ const newStatus = statusBadgeHtml(job.status);
241
+ if (cells[1].innerHTML !== newStatus) cells[1].innerHTML = newStatus;
242
+ const newCwd = escapeHtml(formatCwd(job.cwd));
243
+ if (cells[3].innerHTML !== newCwd) {
244
+ cells[3].innerHTML = newCwd;
245
+ cells[3].title = job.cwd || '';
246
+ }
247
+ const newSession = job.session_id ? job.session_id.slice(0, 8) : (job.status === 'running' ? '—' : '-');
248
+ if (cells[4].textContent !== newSession) {
249
+ cells[4].textContent = newSession;
250
+ if (job.session_id) {
251
+ cells[4].className = 'job-session clickable';
252
+ 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 || '')}')`);
254
+ }
255
+ }
256
+ const newActions = jobActionsHtml(id, job.status, job.session_id, job.cwd);
257
+ if (cells[6].innerHTML !== newActions) {
258
+ cells[6].innerHTML = newActions;
259
+ }
260
+ }
261
+ existing.className = isExpanded ? 'expanded' : '';
262
+ delete existingRows[id];
263
+ } else if (!existing) {
264
+ const tr = document.createElement('tr');
265
+ tr.dataset.jobId = id;
266
+ tr.className = isExpanded ? 'expanded' : '';
267
+ 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>`;
276
+ tbody.appendChild(tr);
277
+ } else {
278
+ delete existingRows[id];
279
+ }
280
+
281
+ const expandKey = id + '__expand';
282
+ const existingExpand = existingRows[expandKey] || tbody.querySelector(`tr[data-job-id="${CSS.escape(expandKey)}"]`);
283
+
284
+ if (isExpanded) {
285
+ if (!existingExpand) {
286
+ const expandTr = document.createElement('tr');
287
+ expandTr.className = 'expand-row';
288
+ 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>`;
302
+ const jobRow = tbody.querySelector(`tr[data-job-id="${CSS.escape(id)}"]`);
303
+ if (jobRow && jobRow.nextSibling) {
304
+ tbody.insertBefore(expandTr, jobRow.nextSibling);
305
+ } else {
306
+ tbody.appendChild(expandTr);
307
+ }
308
+ initStream(id);
309
+ } else {
310
+ delete existingRows[expandKey];
311
+ }
312
+ } else if (existingExpand) {
313
+ existingExpand.remove();
314
+ delete existingRows[expandKey];
315
+ }
316
+
317
+ // 실행 중인 작업: 미리보기 행
318
+ const previewKey = id + '__preview';
319
+ const existingPreview = existingRows[previewKey] || tbody.querySelector(`tr[data-job-id="${CSS.escape(previewKey)}"]`);
320
+
321
+ if (job.status === 'running' && !isExpanded) {
322
+ if (!streamState[id]) {
323
+ streamState[id] = { offset: 0, timer: null, done: false, jobData: job, events: [], renderedCount: 0 };
324
+ }
325
+ if (!streamState[id].timer) {
326
+ initStream(id);
327
+ }
328
+ if (!existingPreview) {
329
+ const pvTr = document.createElement('tr');
330
+ pvTr.className = 'preview-row';
331
+ 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>`;
333
+ const jobRow = tbody.querySelector(`tr[data-job-id="${CSS.escape(id)}"]`);
334
+ if (jobRow && jobRow.nextSibling) {
335
+ tbody.insertBefore(pvTr, jobRow.nextSibling);
336
+ } else {
337
+ tbody.appendChild(pvTr);
338
+ }
339
+ newIds.splice(newIds.indexOf(id) + 1, 0, previewKey);
340
+ } else {
341
+ delete existingRows[previewKey];
342
+ newIds.splice(newIds.indexOf(id) + 1, 0, previewKey);
343
+ }
344
+ updateJobPreview(id);
345
+ } else {
346
+ if (existingPreview) {
347
+ existingPreview.remove();
348
+ delete existingRows[previewKey];
349
+ }
350
+ }
351
+ }
352
+
353
+ for (const [key, row] of Object.entries(existingRows)) {
354
+ row.remove();
355
+ }
356
+
357
+ const currentOrder = [...tbody.querySelectorAll('tr[data-job-id]')].map(r => r.dataset.jobId);
358
+ if (JSON.stringify(currentOrder) !== JSON.stringify(newIds)) {
359
+ for (const nid of newIds) {
360
+ const row = tbody.querySelector(`tr[data-job-id="${CSS.escape(nid)}"]`);
361
+ if (row) tbody.appendChild(row);
362
+ }
363
+ }
364
+
365
+ const hasCompleted = jobs.some(j => j.status === 'done' || j.status === 'failed');
366
+ const deleteBtn = document.getElementById('btnDeleteCompleted');
367
+ deleteBtn.style.display = hasCompleted ? 'inline-flex' : 'none';
368
+ }
369
+
370
+ function updateJobRowStatus(jobId, status) {
371
+ const tbody = document.getElementById('jobTableBody');
372
+ const row = tbody.querySelector(`tr[data-job-id="${CSS.escape(jobId)}"]`);
373
+ if (!row || row.classList.contains('expand-row')) return;
374
+ const cells = row.querySelectorAll('td');
375
+ if (cells.length >= 2) {
376
+ const newBadge = statusBadgeHtml(status);
377
+ if (cells[1].innerHTML !== newBadge) cells[1].innerHTML = newBadge;
378
+ }
379
+ }
380
+
381
+ function toggleJobExpand(id) {
382
+ const tbody = document.getElementById('jobTableBody');
383
+ if (expandedJobId === id) {
384
+ stopStream(expandedJobId);
385
+ const expandRow = tbody.querySelector(`tr[data-job-id="${CSS.escape(id + '__expand')}"]`);
386
+ if (expandRow) expandRow.remove();
387
+ const jobRow = tbody.querySelector(`tr[data-job-id="${CSS.escape(id)}"]`);
388
+ if (jobRow) jobRow.className = '';
389
+ expandedJobId = null;
390
+ } else {
391
+ if (expandedJobId) {
392
+ stopStream(expandedJobId);
393
+ const prevExpand = tbody.querySelector(`tr[data-job-id="${CSS.escape(expandedJobId + '__expand')}"]`);
394
+ if (prevExpand) prevExpand.remove();
395
+ const prevRow = tbody.querySelector(`tr[data-job-id="${CSS.escape(expandedJobId)}"]`);
396
+ if (prevRow) prevRow.className = '';
397
+ }
398
+ expandedJobId = id;
399
+ fetchJobs();
400
+ }
401
+ }
402
+
403
+ async function deleteJob(jobId) {
404
+ try {
405
+ await apiFetch(`/api/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' });
406
+ if (streamState[jobId]) {
407
+ stopStream(jobId);
408
+ delete streamState[jobId];
409
+ }
410
+ if (expandedJobId === jobId) expandedJobId = null;
411
+ showToast(t('msg_job_deleted'));
412
+ fetchJobs();
413
+ } catch (err) {
414
+ showToast(`${t('msg_delete_failed')}: ${err.message}`, 'error');
415
+ }
416
+ }
417
+
418
+ async function deleteCompletedJobs() {
419
+ try {
420
+ const data = await apiFetch('/api/jobs', { method: 'DELETE' });
421
+ const count = data.count || 0;
422
+ for (const id of (data.deleted || [])) {
423
+ if (streamState[id]) {
424
+ stopStream(id);
425
+ delete streamState[id];
426
+ }
427
+ if (expandedJobId === id) expandedJobId = null;
428
+ }
429
+ showToast(count + t('msg_batch_deleted'));
430
+ fetchJobs();
431
+ } catch (err) {
432
+ showToast(`${t('msg_batch_delete_failed')}: ${err.message}`, 'error');
433
+ }
434
+ }
@@ -0,0 +1,31 @@
1
+ /* ── Pipeline ── */
2
+ /* ── Pipeline ── */
3
+ .pipeline-list {
4
+ display: flex;
5
+ flex-direction: column;
6
+ gap: 1px;
7
+ background: var(--border);
8
+ }
9
+ .pipeline-card {
10
+ padding: 14px 16px;
11
+ background: var(--surface);
12
+ }
13
+ .pipeline-card:first-child { border-radius: 0 0 0 0; }
14
+ .pipeline-card:last-child { border-radius: 0 0 8px 8px; }
15
+ .pipeline-card-header {
16
+ display: flex;
17
+ align-items: center;
18
+ justify-content: space-between;
19
+ gap: 8px;
20
+ }
21
+ .pipeline-card-title {
22
+ font-size: 0.82rem;
23
+ font-weight: 600;
24
+ color: var(--text);
25
+ }
26
+ .pipeline-card-goal {
27
+ font-size: 0.75rem;
28
+ color: var(--text-secondary);
29
+ margin-top: 4px;
30
+ line-height: 1.4;
31
+ }