input-kanban 0.0.8 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,15 +1,16 @@
1
1
  {
2
2
  "name": "input-kanban",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "input-kanban": "bin/input-kanban.js"
7
7
  },
8
8
  "scripts": {
9
9
  "start": "node bin/input-kanban.js",
10
- "check": "node --check bin/input-kanban.js && node --check bin/input-kanban-format-events.js && node --check bin/input-kanban-timestamp-events.js && node --check bin/input-kanban-tmux-overview.js && node --check src/server.js && node --check src/orchestrator.js && node --check src/appServerClient.js && node --check src/utils.js && node --check src/eventFormatter.js && node --check src/runners/index.js && node --check src/runners/headlessRunner.js && node --check src/runners/tmuxRunner.js && node --check src/runners/tmuxUtils.js && node --check src/tmux.js && node --test"
10
+ "check": "node --check bin/input-kanban.js && node --check bin/input-kanban-format-events.js && node --check bin/input-kanban-timestamp-events.js && node --check bin/input-kanban-tmux-overview.js && node --check src/server.js && node --check src/scheduler.js && node --check src/orchestrator.js && node --check src/appServerClient.js && node --check src/utils.js && node --check src/eventFormatter.js && node --check src/runners/index.js && node --check src/runners/headlessRunner.js && node --check src/runners/tmuxRunner.js && node --check src/runners/tmuxUtils.js && node --check src/tmux.js && node --test"
11
11
  },
12
12
  "description": "A local Codex orchestration kanban dashboard",
13
+ "license": "MIT",
13
14
  "files": [
14
15
  "bin",
15
16
  "src",
@@ -18,7 +19,8 @@
18
19
  "README.en.md",
19
20
  "RELEASE_NOTES.md",
20
21
  "PROJECT_GUIDE.md",
21
- "ENVIRONMENT.md"
22
+ "ENVIRONMENT.md",
23
+ "LICENSE"
22
24
  ],
23
25
  "keywords": [
24
26
  "codex",
package/public/index.html CHANGED
@@ -22,8 +22,8 @@
22
22
  .sidebar { position: sticky; top: 18px; height: calc(100vh - 36px); }
23
23
  .sidebar section { height: 100%; display: flex; flex-direction: column; box-sizing: border-box; }
24
24
  section { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 16px; box-shadow: 0 8px 24px rgba(0,0,0,.18); }
25
- textarea, input { width: 100%; box-sizing: border-box; background: #020617; color: var(--text); border: 1px solid #475569; border-radius: 9px; padding: 9px 10px; outline: none; }
26
- textarea:focus, input:focus { border-color: #60a5fa; box-shadow: 0 0 0 2px rgba(37,99,235,.25); }
25
+ textarea, input, select { width: 100%; box-sizing: border-box; background: #020617; color: var(--text); border: 1px solid #475569; border-radius: 9px; padding: 9px 10px; outline: none; }
26
+ textarea:focus, input:focus, select:focus { border-color: #60a5fa; box-shadow: 0 0 0 2px rgba(37,99,235,.25); }
27
27
  textarea { min-height: 240px; }
28
28
  label { display: block; margin-top: 10px; color: #cbd5e1; font-weight: 700; }
29
29
  button { background: var(--blue); color: white; border: 0; border-radius: 9px; padding: 8px 11px; margin: 4px 4px 4px 0; cursor: pointer; font-weight: 700; }
@@ -60,8 +60,12 @@
60
60
  .muted { color: var(--muted); font-size: 12px; }
61
61
  .hidden { display: none; }
62
62
  .toolbar { margin: 8px 0 12px; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
63
+ .workspace-filter-panel { margin: 4px 0 10px; display: flex; align-items: center; gap: 6px; }
64
+ .workspace-filter-select { flex: 1 1 auto; min-width: 0; font-size: 12px; padding: 7px 9px; color: #cbd5e1; }
63
65
  .task-text { max-height: 180px; color: #cbd5e1; }
64
66
  .empty { color: var(--muted); padding: 18px 0; }
67
+ .runs-load-icon { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: 10px; cursor: help; opacity: .72; }
68
+ .runs-load-icon:hover { opacity: 1; color: #cbd5e1; border-color: var(--line-strong); }
65
69
  .run-list { display: flex; flex-direction: column; gap: 10px; flex: 1; min-height: 0; overflow-y: auto; padding-right: 4px; }
66
70
  .run-list-more { width: 100%; margin-top: 4px; }
67
71
  .run-card { border: 1px solid var(--line); border-radius: 12px; padding: 12px; background: var(--panel-2); cursor: pointer; transition: border-color .15s, transform .15s, background .15s; }
@@ -119,6 +123,7 @@
119
123
  .modal-backdrop.hidden { display: none; }
120
124
  .modal-card { width: min(760px, 100%); border: 1px solid var(--line); border-radius: 14px; background: var(--panel); box-shadow: 0 18px 60px rgba(0,0,0,.38); padding: 16px; }
121
125
  .modal-card textarea { min-height: 220px; }
126
+ .page-footer { padding: 0 18px 18px; color: var(--muted); text-align: center; font-size: 12px; }
122
127
  </style>
123
128
  </head>
124
129
  <body>
@@ -128,21 +133,24 @@
128
133
  <section>
129
134
  <div class="toolbar">
130
135
  <button onclick="showCreateForm()">新建任务批次</button>
131
- <button class="secondary" onclick="refreshRuns()">刷新批次列表</button>
132
136
  </div>
133
137
  <h2>任务批次</h2>
134
- <div id="runs" class="run-list"></div>
138
+ <div class="workspace-filter-panel">
139
+ <select id="workspaceFilterSelect" class="workspace-filter-select" onchange="setWorkspaceFilter(this.value)" title="未筛选工作区"></select>
140
+ <span id="runsLoadHint" class="runs-load-icon" title="批次列表尚未加载" aria-label="批次列表尚未加载">ⓘ</span>
141
+ </div>
142
+ <div id="runs" class="run-list"><div class="empty">批次列表加载中...</div></div>
135
143
  </section>
136
144
  </div>
137
145
  <div>
138
146
  <section id="createPanel" class="hidden">
139
147
  <h2>新建任务批次</h2>
140
148
  <label>批次名称</label><input id="label" value="codex-task" />
141
- <label>目标仓库</label><input id="repo" />
149
+ <label>工作区</label><input id="repo" />
142
150
  <label>运行目录</label><input id="runsDir" readonly />
143
151
  <label>最大并发数</label><input id="maxParallel" type="number" value="3" min="1" max="16" />
144
152
  <label>Worker 沙箱</label><select id="workerSandbox">
145
- <option value="workspace-write" selected>workspace-write(默认,允许写当前仓库)</option>
153
+ <option value="workspace-write" selected>workspace-write(默认,允许写当前工作区)</option>
146
154
  <option value="read-only">read-only(只读)</option>
147
155
  <option value="danger-full-access">danger-full-access(高风险,跳过沙箱限制)</option>
148
156
  </select>
@@ -186,6 +194,7 @@
186
194
  </section>
187
195
  </div>
188
196
  </main>
197
+ <footer id="pageFooter" class="page-footer">版本:-</footer>
189
198
  <div id="manualCompleteModal" class="modal-backdrop hidden">
190
199
  <div class="modal-card">
191
200
  <h2>手动标记成功</h2>
@@ -209,9 +218,13 @@ let currentState = null;
209
218
  let lastAutoRefreshAt = null;
210
219
  let runListVisibleCount = 10;
211
220
  let latestRuns = [];
221
+ let workspaceCatalogRuns = [];
212
222
  const statusByRunId = new Map();
213
223
  const AUTO_REFRESH_MS = 3000;
214
224
  const RUN_LIST_PAGE_SIZE = 10;
225
+ const WORKSPACE_FILTER_ALL = '';
226
+ let currentWorkspacePath = '';
227
+ let selectedWorkspaceFilter = localStorage.getItem('input-kanban.workspaceFilter') || WORKSPACE_FILTER_ALL;
215
228
 
216
229
  async function api(path, opts={}) {
217
230
  const res = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...opts });
@@ -284,6 +297,7 @@ function basenamePath(value) {
284
297
  function metaChip(label, value, { title = value, danger = false, long = false, extra = '' } = {}) {
285
298
  return `<span class="meta-chip ${danger ? 'danger' : ''} ${long ? 'long' : ''}" title="${esc(title)}"><span class="meta-label">${esc(label)}</span><span class="meta-value">${esc(value)}</span>${extra}</span>`;
286
299
  }
300
+ function gitChip() { return '<span class="meta-chip" title="Git 工作区"><span class="meta-value">Git</span></span>'; }
287
301
  function editIcon() {
288
302
  return '<svg class="icon-svg" viewBox="0 0 24 24" focusable="false" aria-hidden="true"><path d="M4 16.5V20h3.5L18.1 9.4l-3.5-3.5L4 16.5Z" fill="currentColor"/><path d="m16 4.5 3.5 3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
289
303
  }
@@ -328,8 +342,12 @@ function runListHasTmuxMetadata(run) {
328
342
 
329
343
  async function loadHealth() {
330
344
  const h = await api('/api/health');
331
- document.getElementById('repo').value = h.defaultRepo;
345
+ currentWorkspacePath = h.defaultWorkspace || h.defaultRepo || '';
346
+ document.getElementById('repo').value = currentWorkspacePath;
332
347
  document.getElementById('runsDir').value = h.runsDir;
348
+ document.getElementById('pageFooter').textContent = h.version ? `版本:v${h.version}` : '版本:未知(请重启服务)';
349
+ renderWorkspaceFilterOptions();
350
+ updateWorkspaceFilterTitle();
333
351
  }
334
352
  function showCreateForm() {
335
353
  selectedRun = null; selectedTask = null; selectedFileName = null; currentState = null;
@@ -344,7 +362,7 @@ function hideCreateForm() {
344
362
  document.getElementById('filePanel').classList.remove('hidden');
345
363
  }
346
364
  async function createRun() {
347
- const body = { label: label.value, repo: repo.value, maxParallel: maxParallel.value, workerSandbox: workerSandbox.value, taskText: taskText.value };
365
+ const body = { label: label.value, workspace: repo.value, repo: repo.value, maxParallel: maxParallel.value, workerSandbox: workerSandbox.value, taskText: taskText.value };
348
366
  const r = await api('/api/runs', { method: 'POST', body: JSON.stringify(body) });
349
367
  selectedRun = r.runId; selectedTask = null; selectedFileName = null;
350
368
  clearFileView();
@@ -354,9 +372,28 @@ async function createRun() {
354
372
  await refreshSelected();
355
373
  }
356
374
  async function refreshRuns() {
357
- const data = await api('/api/runs');
358
- latestRuns = data.runs || [];
359
- renderRunList();
375
+ const startedAt = performance.now();
376
+ const hint = document.getElementById('runsLoadHint');
377
+ if (hint) { hint.title = '批次列表加载中...'; hint.setAttribute('aria-label', hint.title); }
378
+ if (!latestRuns.length) document.getElementById('runs').innerHTML = '<div class="empty">批次列表加载中...</div>';
379
+ try {
380
+ const params = new URLSearchParams();
381
+ if (selectedWorkspaceFilter && selectedWorkspaceFilter !== WORKSPACE_FILTER_ALL) params.set('workspace', selectedWorkspaceFilter);
382
+ const [filteredData, catalogData] = await Promise.all([
383
+ api(`/api/runs${params.toString() ? `?${params.toString()}` : ''}`),
384
+ api('/api/runs?includeArchived=1')
385
+ ]);
386
+ latestRuns = filteredData.runs || [];
387
+ workspaceCatalogRuns = catalogData.runs || [];
388
+ renderWorkspaceFilterOptions();
389
+ renderRunList();
390
+ updateWorkspaceFilterTitle();
391
+ if (hint) { hint.title = `加载 ${Math.round(performance.now() - startedAt)}ms|显示 ${latestRuns.length} 个批次`; hint.setAttribute('aria-label', hint.title); }
392
+ } catch (error) {
393
+ console.error('批次列表加载失败', error);
394
+ if (hint) { hint.title = `批次列表加载失败|${errorDetail(error)}`; hint.setAttribute('aria-label', hint.title); }
395
+ if (!latestRuns.length) document.getElementById('runs').innerHTML = '<div class="empty">批次列表加载失败</div>';
396
+ }
360
397
  }
361
398
  function renderRunList() {
362
399
  const visibleRuns = latestRuns.slice(0, runListVisibleCount);
@@ -364,7 +401,8 @@ function renderRunList() {
364
401
  <div class="run-card ${selectedRun === r.runId ? 'active' : ''}" onclick="selectRun('${r.runId}')" onmouseleave="clearArchiveConfirm('${r.runId}')">
365
402
  <div class="run-card-title"><span class="run-card-name-wrap"><span class="run-card-name" title="${esc(r.label)}">${esc(r.label)}</span><span class="run-card-title-actions"><button class="secondary copy-btn rename-btn" title="修改任务批次名称" onclick="renameRunLabel(event, '${r.runId}')">${editIcon()}</button><button class="secondary copy-btn rename-btn ${pendingArchiveRunId === r.runId ? 'archive-confirm-btn' : ''}" title="${pendingArchiveRunId === r.runId ? '再次点击确认归档' : '归档任务批次(运行中请先停止)'}" onclick="archiveRunFromCard(event, '${r.runId}')">${pendingArchiveRunId === r.runId ? '确认' : archiveIcon()}</button></span></span><span>${pill(r.status)}</span></div>
366
403
  <div class="run-card-meta">
367
- ${metaChip('仓库', basenamePath(r.repo), { title: r.repo, long: true, extra: `<button class="secondary copy-btn" title="复制仓库地址" onclick="copyRunRepoPath(event, '${r.runId}')">⧉</button>` })}
404
+ ${metaChip('工作区', basenamePath(r.workspacePath || r.repo), { title: r.workspacePath || r.repo, long: true, extra: `<button class="secondary copy-btn" title="复制工作区地址" onclick="copyRunRepoPath(event, '${r.runId}')">⧉</button>` })}
405
+ ${r.git?.isGit ? gitChip() : ''}
368
406
  ${metaChip('创建', formatDateTime(r.createdAt))}
369
407
  ${metaChip('用时', runCardDurationText(r))}
370
408
  ${metaChip('进度', `${r.completed}/${r.total}`)}
@@ -381,6 +419,46 @@ function showMoreRuns() {
381
419
  runListVisibleCount += RUN_LIST_PAGE_SIZE;
382
420
  renderRunList();
383
421
  }
422
+ function renderWorkspaceFilterOptions() {
423
+ const select = document.getElementById('workspaceFilterSelect');
424
+ if (!select) return;
425
+ const workspaces = new Map();
426
+ for (const run of workspaceCatalogRuns) {
427
+ const workspacePath = run.workspacePath || run.repo || '';
428
+ if (!workspacePath) continue;
429
+ const item = workspaces.get(workspacePath) || { label: basenamePath(workspacePath), count: 0, git: !!run.git?.isGit };
430
+ item.count += 1;
431
+ item.git = item.git || !!run.git?.isGit;
432
+ workspaces.set(workspacePath, item);
433
+ }
434
+ const currentValue = workspaces.has(selectedWorkspaceFilter) ? selectedWorkspaceFilter : WORKSPACE_FILTER_ALL;
435
+ if (currentValue !== selectedWorkspaceFilter) {
436
+ selectedWorkspaceFilter = currentValue;
437
+ localStorage.setItem('input-kanban.workspaceFilter', selectedWorkspaceFilter);
438
+ }
439
+ const options = [[WORKSPACE_FILTER_ALL, '工作区筛选'], ...[...workspaces.entries()].map(([workspacePath, item]) => {
440
+ const gitText = item.git ? ' · Git' : '';
441
+ return [workspacePath, `${item.label}${gitText} (${item.count})`];
442
+ })];
443
+ select.innerHTML = options.map(([value, label]) => `<option value="${esc(value)}">${esc(label)}</option>`).join('');
444
+ select.value = selectedWorkspaceFilter;
445
+ updateWorkspaceFilterTitle();
446
+ }
447
+ function updateWorkspaceFilterTitle() {
448
+ const select = document.getElementById('workspaceFilterSelect');
449
+ if (!select) return;
450
+ if (selectedWorkspaceFilter === WORKSPACE_FILTER_ALL) {
451
+ select.title = currentWorkspacePath ? `未筛选工作区|默认工作区:${currentWorkspacePath}` : '未筛选工作区';
452
+ } else {
453
+ select.title = `当前筛选:${selectedWorkspaceFilter}`;
454
+ }
455
+ }
456
+ function setWorkspaceFilter(value) {
457
+ selectedWorkspaceFilter = String(value || '').trim();
458
+ localStorage.setItem('input-kanban.workspaceFilter', selectedWorkspaceFilter);
459
+ runListVisibleCount = RUN_LIST_PAGE_SIZE;
460
+ refreshRuns().catch(console.error);
461
+ }
384
462
  async function selectRun(id) { selectedRun = id; selectedTask = null; selectedFileName = null; clearFileView(); hideCreateForm(); await refreshSelected(); }
385
463
  async function refreshSelected({auto=false} = {}) {
386
464
  if (!selectedRun) return;
@@ -400,15 +478,18 @@ function renderSelectedHeader() {
400
478
  const sandbox = currentState.workerSandbox || 'workspace-write';
401
479
  const chips = [
402
480
  metaChip('Run ID', currentState.runId, { long: true }),
403
- metaChip('仓库', basenamePath(currentState.repo), {
404
- title: currentState.repo,
481
+ metaChip('工作区', basenamePath(currentState.workspacePath || currentState.repo), {
482
+ title: currentState.workspacePath || currentState.repo,
405
483
  long: true,
406
- extra: `<button class="secondary copy-btn" title="复制仓库地址" onclick="copyRepoPath(event)">⧉</button>`
484
+ extra: `<button class="secondary copy-btn" title="复制工作区地址" onclick="copyRepoPath(event)">⧉</button>`
407
485
  }),
408
486
  metaChip('沙箱', sandbox, { danger: sandbox === 'danger-full-access' }),
409
487
  metaChip('开始', formatDateTime(currentState.createdAt)),
410
488
  metaChip('用时', formatDurationMs(durationSeconds(currentState.createdAt, runDurationEnd(currentState)) * 1000))
411
489
  ];
490
+ if (currentState.git?.isGit || currentState.workspace?.git?.isGit) {
491
+ chips.push(gitChip());
492
+ }
412
493
  if (currentState.runner === 'tmux') {
413
494
  if (hasRunTmuxMetadata(currentState)) {
414
495
  chips.push(metaChip('终端', tmuxSessionName(currentState), {
@@ -463,9 +544,9 @@ function updateAutoRefreshHint() {
463
544
  const el = document.getElementById('autoRefreshHint');
464
545
  if (!el) return;
465
546
  if (!selectedRun || !currentState) { el.textContent = '自动刷新:未启动'; return; }
466
- const active = ['planning','running','judging'].includes(currentState.status) || (currentState.tasks || []).some(t => t.status === 'running') || currentState.planner?.status === 'running' || currentState.judge?.status === 'running';
547
+ const active = ['planning','running','judging','planned','batches_completed','batch_blocked'].includes(currentState.status) || (currentState.tasks || []).some(t => t.status === 'running') || currentState.planner?.status === 'running' || currentState.judge?.status === 'running';
467
548
  const last = lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发';
468
- el.textContent = active ? `自动刷新中:每 ${AUTO_REFRESH_MS / 1000} 秒刷新一次|上次刷新 ${last}` : `自动刷新待命:每 ${AUTO_REFRESH_MS / 1000} 秒检查一次|上次刷新 ${last}`;
549
+ el.textContent = active ? `自动模式中:每 ${AUTO_REFRESH_MS / 1000} 秒刷新并推进一次|上次刷新 ${last}` : `自动模式待命:每 ${AUTO_REFRESH_MS / 1000} 秒检查一次|上次刷新 ${last}`;
469
550
  }
470
551
  function taskStatusCell(t) {
471
552
  if (t?.manualCompletion) {
@@ -623,7 +704,7 @@ function hideExecutionSummary() {
623
704
  el.classList.add('hidden');
624
705
  el.innerHTML = '';
625
706
  }
626
- async function copyRepoPath(event, repoPath = currentState?.repo || '') {
707
+ async function copyRepoPath(event, repoPath = currentState?.workspacePath || currentState?.repo || '') {
627
708
  event.stopPropagation();
628
709
  if (!repoPath) return;
629
710
  try {
@@ -631,11 +712,11 @@ async function copyRepoPath(event, repoPath = currentState?.repo || '') {
631
712
  event.currentTarget.textContent = '已复制';
632
713
  setTimeout(() => { event.currentTarget.textContent = '⧉'; }, 900);
633
714
  } catch {
634
- prompt('复制仓库地址', repoPath);
715
+ prompt('复制工作区地址', repoPath);
635
716
  }
636
717
  }
637
718
  async function copyRunRepoPath(event, runId) {
638
- const repoPath = latestRuns.find(run => run.runId === runId)?.repo || '';
719
+ const repoPath = latestRuns.find(run => run.runId === runId)?.workspacePath || latestRuns.find(run => run.runId === runId)?.repo || '';
639
720
  await copyRepoPath(event, repoPath);
640
721
  }
641
722
  async function copyTmuxRunCommand(event) {