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,311 @@
1
+ /* ═══════════════════════════════════════════════
2
+ Stream — 스트림 폴링, 렌더링, 복사
3
+ ═══════════════════════════════════════════════ */
4
+
5
+ const streamState = {};
6
+
7
+ function updateJobPreview(jobId) {
8
+ const el = document.getElementById(`jobPreview-${jobId}`);
9
+ if (!el) return;
10
+
11
+ const state = streamState[jobId];
12
+ if (!state || state.events.length === 0) return;
13
+
14
+ const recent = state.events.slice(-2);
15
+ const lines = recent.map(evt => {
16
+ if (evt.type === 'tool_use') {
17
+ const input = escapeHtml((typeof evt.input === 'string' ? evt.input : JSON.stringify(evt.input || '')).slice(0, 150));
18
+ return `<div class="preview-line"><span class="preview-tool">${escapeHtml(evt.tool || 'Tool')}</span>${input}</div>`;
19
+ }
20
+ if (evt.type === 'result') {
21
+ const text = (typeof evt.result === 'string' ? evt.result : '').slice(0, 150);
22
+ return `<div class="preview-line preview-result">${escapeHtml(text)}</div>`;
23
+ }
24
+ const text = (evt.text || '').split('\n').pop().slice(0, 150);
25
+ if (!text) return '';
26
+ return `<div class="preview-line">${escapeHtml(text)}</div>`;
27
+ }).filter(Boolean);
28
+
29
+ if (lines.length > 0) {
30
+ el.innerHTML = `<div class="preview-lines">${lines.join('')}</div>`;
31
+ }
32
+ }
33
+
34
+ function initStream(jobId) {
35
+ if (streamState[jobId] && streamState[jobId].timer) return;
36
+ if (streamState[jobId] && streamState[jobId]._bulkLoading) return;
37
+
38
+ if (!streamState[jobId]) {
39
+ streamState[jobId] = { offset: 0, timer: null, done: false, jobData: null, events: [], renderedCount: 0 };
40
+ }
41
+
42
+ const state = streamState[jobId];
43
+ if (state.done && state.events.length > 0) {
44
+ renderStreamEvents(jobId);
45
+ return;
46
+ }
47
+
48
+ const isDone = state.jobData && (state.jobData.status === 'done' || state.jobData.status === 'failed');
49
+ if (isDone) {
50
+ loadStreamBulk(jobId);
51
+ return;
52
+ }
53
+
54
+ pollStream(jobId);
55
+ state.timer = setInterval(() => pollStream(jobId), 500);
56
+ }
57
+
58
+ async function loadStreamBulk(jobId) {
59
+ const state = streamState[jobId];
60
+ if (!state || state._bulkLoading) return;
61
+ state._bulkLoading = true;
62
+
63
+ try {
64
+ const data = await apiFetch(`/api/jobs/${encodeURIComponent(jobId)}/stream?offset=${state.offset}`);
65
+ if (data.events && data.events.length > 0) {
66
+ state.events = state.events.concat(data.events);
67
+ state.offset = data.offset;
68
+ renderStreamEvents(jobId);
69
+ }
70
+ if (data.done || !data.events || data.events.length === 0) {
71
+ state.done = true;
72
+ renderStreamDone(jobId);
73
+ updateJobRowStatus(jobId, state.jobData ? state.jobData.status : 'done');
74
+ const pvRow = document.querySelector(`tr[data-job-id="${CSS.escape(jobId + '__preview')}"]`);
75
+ if (pvRow) pvRow.remove();
76
+ }
77
+ } catch {
78
+ // 네트워크 오류 시 재시도 가능
79
+ } finally {
80
+ state._bulkLoading = false;
81
+ }
82
+ }
83
+
84
+ function stopStream(jobId) {
85
+ const state = streamState[jobId];
86
+ if (!state) return;
87
+ if (state.timer) {
88
+ clearInterval(state.timer);
89
+ state.timer = null;
90
+ }
91
+ }
92
+
93
+ async function pollStream(jobId) {
94
+ const state = streamState[jobId];
95
+ if (!state || state.done) {
96
+ stopStream(jobId);
97
+ return;
98
+ }
99
+
100
+ try {
101
+ const data = await apiFetch(`/api/jobs/${encodeURIComponent(jobId)}/stream?offset=${state.offset}`);
102
+ const events = data.events || [];
103
+ const newOffset = data.offset !== undefined ? data.offset : state.offset + events.length;
104
+ const done = !!data.done;
105
+
106
+ if (events.length > 0) {
107
+ state.events = state.events.concat(events);
108
+ state.offset = newOffset;
109
+ renderStreamEvents(jobId);
110
+ updateJobPreview(jobId);
111
+ }
112
+
113
+ if (done) {
114
+ state.done = true;
115
+ stopStream(jobId);
116
+ renderStreamDone(jobId);
117
+ updateJobRowStatus(jobId, state.jobData ? state.jobData.status : 'done');
118
+ const pvRow = document.querySelector(`tr[data-job-id="${CSS.escape(jobId + '__preview')}"]`);
119
+ if (pvRow) pvRow.remove();
120
+ }
121
+ } catch {
122
+ // Network error — keep retrying
123
+ }
124
+ }
125
+
126
+ function renderStreamEvents(jobId) {
127
+ const container = document.getElementById(`streamContent-${jobId}`);
128
+ if (!container) return;
129
+
130
+ const state = streamState[jobId];
131
+ if (!state || state.events.length === 0) return;
132
+
133
+ if (!state.renderedCount) state.renderedCount = 0;
134
+ if (state.renderedCount >= state.events.length) return;
135
+
136
+ const wasAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 40;
137
+
138
+ if (state.renderedCount === 0) {
139
+ container.innerHTML = '';
140
+ }
141
+
142
+ const fragment = document.createDocumentFragment();
143
+ for (let i = state.renderedCount; i < state.events.length; i++) {
144
+ const evt = state.events[i];
145
+ const type = (evt.type || 'text').toLowerCase();
146
+ const div = document.createElement('div');
147
+ div.className = 'stream-event';
148
+
149
+ switch (type) {
150
+ case 'tool_use':
151
+ div.classList.add('stream-event-tool');
152
+ div.innerHTML = `<span class="stream-tool-badge">${escapeHtml(evt.tool || 'Tool')}</span>
153
+ <span class="stream-tool-input">${escapeHtml(typeof evt.input === 'string' ? evt.input : JSON.stringify(evt.input || ''))}</span>`;
154
+ break;
155
+ case 'result':
156
+ div.classList.add('stream-event-result');
157
+ div.innerHTML = `<span class="stream-result-icon">✓</span>
158
+ <span class="stream-result-text">${escapeHtml(typeof evt.result === 'string' ? evt.result : JSON.stringify(evt.result || ''))}</span>`;
159
+ if (evt.session_id) {
160
+ const panel = document.getElementById(`streamPanel-${jobId}`);
161
+ if (panel) panel.dataset.sessionId = evt.session_id;
162
+ const jobRow = document.querySelector(`tr[data-job-id="${CSS.escape(jobId)}"]`);
163
+ if (jobRow) {
164
+ const cells = jobRow.querySelectorAll('td');
165
+ if (cells.length >= 5 && cells[4].textContent !== evt.session_id.slice(0, 8)) {
166
+ cells[4].textContent = evt.session_id.slice(0, 8);
167
+ cells[4].className = 'job-session clickable';
168
+ cells[4].title = evt.session_id;
169
+ const evtCwd = panel ? (panel.dataset.cwd || '') : '';
170
+ cells[4].setAttribute('onclick', `event.stopPropagation(); resumeFromJob('${escapeHtml(evt.session_id)}', '', '${escapeHtml(evtCwd)}')`);
171
+ }
172
+ }
173
+ }
174
+ break;
175
+ case 'error':
176
+ div.classList.add('stream-event-error');
177
+ div.innerHTML = `<span class="stream-error-icon">✗</span>
178
+ <span class="stream-error-text">${escapeHtml(evt.text || evt.error || evt.message || 'Unknown error')}</span>`;
179
+ break;
180
+ case 'text':
181
+ default:
182
+ div.classList.add('stream-event-text');
183
+ div.textContent = evt.text || '';
184
+ break;
185
+ }
186
+ fragment.appendChild(div);
187
+ }
188
+
189
+ container.appendChild(fragment);
190
+ state.renderedCount = state.events.length;
191
+
192
+ if (wasAtBottom) {
193
+ container.scrollTop = container.scrollHeight;
194
+ }
195
+ }
196
+
197
+ function renderStreamDone(jobId) {
198
+ const panel = document.getElementById(`streamPanel-${jobId}`);
199
+ if (!panel) return;
200
+
201
+ const state = streamState[jobId];
202
+ const status = state && state.jobData ? state.jobData.status : 'done';
203
+ const isFailed = status === 'failed';
204
+
205
+ let banner = panel.querySelector('.stream-done-banner');
206
+ if (!banner) {
207
+ banner = document.createElement('div');
208
+ banner.className = `stream-done-banner${isFailed ? ' failed' : ''}`;
209
+ banner.textContent = isFailed ? '✗ 작업 실패' : '✓ 작업 완료';
210
+ panel.appendChild(banner);
211
+ }
212
+
213
+ let actions = panel.querySelector('.stream-actions');
214
+ if (!actions) {
215
+ actions = document.createElement('div');
216
+ actions.className = 'stream-actions';
217
+ actions.innerHTML = `
218
+ <button class="btn btn-sm" onclick="event.stopPropagation(); copyStreamResult('${escapeHtml(jobId)}')">
219
+ <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>
220
+ 전체 복사
221
+ </button>
222
+ <button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); deleteJob('${escapeHtml(jobId)}')">
223
+ <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>
224
+ 작업 제거
225
+ </button>`;
226
+ panel.appendChild(actions);
227
+ }
228
+
229
+ const sessionId = panel.dataset.sessionId;
230
+ if (sessionId && !panel.querySelector('.stream-followup')) {
231
+ const followup = document.createElement('div');
232
+ followup.className = 'stream-followup';
233
+ followup.innerHTML = `
234
+ <span class="stream-followup-label">이어서</span>
235
+ <div class="followup-input-wrap">
236
+ <input type="text" class="followup-input" id="followupInput-${escapeHtml(jobId)}"
237
+ placeholder="이 세션에 이어서 실행할 명령... (파일/이미지 붙여넣기 가능)"
238
+ onkeydown="if(event.key==='Enter'){event.stopPropagation();sendFollowUp('${escapeHtml(jobId)}')}"
239
+ onclick="event.stopPropagation()">
240
+ <button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); sendFollowUp('${escapeHtml(jobId)}')" style="white-space:nowrap;">
241
+ <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> 전송
242
+ </button>
243
+ </div>
244
+ <div class="followup-previews" id="followupPreviews-${escapeHtml(jobId)}"></div>`;
245
+ panel.appendChild(followup);
246
+
247
+ const fInput = document.getElementById(`followupInput-${jobId}`);
248
+ if (fInput) {
249
+ fInput.addEventListener('paste', function(e) {
250
+ const files = e.clipboardData?.files;
251
+ if (files && files.length > 0) {
252
+ e.preventDefault();
253
+ handleFollowUpFiles(jobId, files);
254
+ }
255
+ });
256
+ fInput.addEventListener('drop', function(e) {
257
+ if (e.dataTransfer.files.length > 0) {
258
+ e.preventDefault();
259
+ handleFollowUpFiles(jobId, e.dataTransfer.files);
260
+ }
261
+ });
262
+ fInput.addEventListener('dragover', function(e) { e.preventDefault(); });
263
+ }
264
+ }
265
+ }
266
+
267
+ function copyStreamResult(jobId) {
268
+ const state = streamState[jobId];
269
+ if (!state || state.events.length === 0) {
270
+ showToast(t('msg_copy_no_result'), 'error');
271
+ return;
272
+ }
273
+
274
+ const textParts = [];
275
+ for (const evt of state.events) {
276
+ const type = (evt.type || 'text').toLowerCase();
277
+ switch (type) {
278
+ case 'text':
279
+ if (evt.text) textParts.push(evt.text);
280
+ break;
281
+ case 'result': {
282
+ const r = typeof evt.result === 'string' ? evt.result : JSON.stringify(evt.result || '');
283
+ if (r) textParts.push(`[Result] ${r}`);
284
+ break;
285
+ }
286
+ case 'tool_use': {
287
+ const toolName = evt.tool || 'Tool';
288
+ const toolInput = typeof evt.input === 'string' ? evt.input : JSON.stringify(evt.input || '');
289
+ textParts.push(`[${toolName}] ${toolInput}`);
290
+ break;
291
+ }
292
+ case 'error': {
293
+ const errMsg = evt.text || evt.error || evt.message || 'Unknown error';
294
+ textParts.push(`[Error] ${errMsg}`);
295
+ break;
296
+ }
297
+ }
298
+ }
299
+
300
+ const text = textParts.join('\n').trim();
301
+ if (!text) {
302
+ showToast(t('msg_copy_no_text'), 'error');
303
+ return;
304
+ }
305
+
306
+ navigator.clipboard.writeText(text).then(() => {
307
+ showToast(t('msg_copy_done'));
308
+ }).catch(() => {
309
+ showToast(t('msg_copy_failed'), 'error');
310
+ });
311
+ }
@@ -0,0 +1,79 @@
1
+ /* ═══════════════════════════════════════════════
2
+ Utility Functions
3
+ ═══════════════════════════════════════════════ */
4
+
5
+ function showToast(message, type = 'success') {
6
+ const container = document.getElementById('toastContainer');
7
+ const toast = document.createElement('div');
8
+ toast.className = `toast ${type}`;
9
+ const icon = type === 'success'
10
+ ? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>'
11
+ : '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>';
12
+ toast.innerHTML = `${icon} ${escapeHtml(message)}`;
13
+ container.appendChild(toast);
14
+ setTimeout(() => { if (toast.parentNode) toast.remove(); }, 3000);
15
+ }
16
+
17
+ function escapeHtml(str) {
18
+ if (!str) return '';
19
+ const d = document.createElement('div');
20
+ d.textContent = str;
21
+ return d.innerHTML;
22
+ }
23
+
24
+ function truncate(str, len = 60) {
25
+ if (!str) return '-';
26
+ return str.length > len ? str.slice(0, len) + '...' : str;
27
+ }
28
+
29
+ function renderPromptHtml(prompt) {
30
+ if (!prompt) return '-';
31
+ const text = truncate(prompt, 200);
32
+ const escaped = escapeHtml(text);
33
+ return escaped.replace(/@(\/[^\s,]+|image\d+)/g, (match, ref) => {
34
+ const isImage = ref.startsWith('image');
35
+ const label = isImage ? ref : ref.split('/').pop();
36
+ const icon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>';
37
+ return `<span class="prompt-img-chip" title="${escapeHtml('@' + ref)}">${icon}${escapeHtml(label)}</span>`;
38
+ });
39
+ }
40
+
41
+ function formatTime(ts) {
42
+ if (!ts) return '-';
43
+ try {
44
+ const d = new Date(ts);
45
+ if (isNaN(d.getTime())) return ts;
46
+ const pad = n => String(n).padStart(2, '0');
47
+ return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
48
+ } catch { return ts; }
49
+ }
50
+
51
+ function formatCwd(cwd) {
52
+ if (!cwd) return '-';
53
+ const parts = cwd.replace(/\/$/, '').split('/');
54
+ return parts[parts.length - 1] || cwd;
55
+ }
56
+
57
+ function formatElapsed(startTs) {
58
+ if (!startTs) return '--:--';
59
+ const start = new Date(startTs).getTime();
60
+ if (isNaN(start)) return '--:--';
61
+ const elapsed = Math.max(0, Math.floor((Date.now() - start) / 1000));
62
+ const h = Math.floor(elapsed / 3600);
63
+ const m = Math.floor((elapsed % 3600) / 60);
64
+ const s = elapsed % 60;
65
+ const pad = n => String(n).padStart(2, '0');
66
+ if (h > 0) return `${h}:${pad(m)}:${pad(s)}`;
67
+ return `${pad(m)}:${pad(s)}`;
68
+ }
69
+
70
+ function formatFileSize(bytes) {
71
+ if (bytes < 1024) return bytes + ' B';
72
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
73
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
74
+ }
75
+
76
+ function getFileExt(filename) {
77
+ const dot = filename.lastIndexOf('.');
78
+ return dot >= 0 ? filename.slice(dot + 1).toUpperCase() : '?';
79
+ }