@aion0/forge 0.4.3 → 0.4.5

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.
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import React, { useState, useEffect, useCallback, memo } from 'react';
4
+ import { useSidebarResize } from '@/hooks/useSidebarResize';
4
5
 
5
6
  // ─── Syntax highlighting ─────────────────────────────────
6
7
  const KEYWORDS = new Set([
@@ -49,6 +50,7 @@ interface GitInfo {
49
50
  }
50
51
 
51
52
  export default memo(function ProjectDetail({ projectPath, projectName, hasGit }: { projectPath: string; projectName: string; hasGit: boolean }) {
53
+ const { sidebarWidth, onSidebarDragStart } = useSidebarResize({ defaultWidth: 208, minWidth: 120, maxWidth: 400 });
52
54
  const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
53
55
  const [loading, setLoading] = useState(false);
54
56
  const [commitMsg, setCommitMsg] = useState('');
@@ -64,15 +66,13 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
64
66
  const [diffFile, setDiffFile] = useState<string | null>(null);
65
67
  const [projectSkills, setProjectSkills] = useState<{ name: string; displayName: string; type: string; scope: string; version: string; installedVersion: string; hasUpdate: boolean; source: 'registry' | 'local' }[]>([]);
66
68
  const [showSkillsDetail, setShowSkillsDetail] = useState(false);
67
- const [projectTab, setProjectTab] = useState<'code' | 'skills' | 'claudemd' | 'issues'>('code');
68
- // Issue autofix state
69
- const [issueConfig, setIssueConfig] = useState<{ enabled: boolean; interval: number; labels: string[]; baseBranch: string } | null>(null);
70
- const [issueProcessed, setIssueProcessed] = useState<{ issueNumber: number; pipelineId: string; prNumber: number | null; status: string; createdAt: string }[]>([]);
71
- const [issueScanning, setIssueScanning] = useState(false);
72
- const [issueManualId, setIssueManualId] = useState('');
73
- const [issueNextScan, setIssueNextScan] = useState<string | null>(null);
74
- const [issueLastScan, setIssueLastScan] = useState<string | null>(null);
75
- const [retryModal, setRetryModal] = useState<{ issueNumber: number; context: string } | null>(null);
69
+ const [projectTab, setProjectTab] = useState<'code' | 'skills' | 'claudemd' | 'pipelines'>('code');
70
+ // Pipeline bindings state
71
+ const [pipelineBindings, setPipelineBindings] = useState<{ id: number; workflowName: string; enabled: boolean; config: any; lastRunAt: string | null; nextRunAt: string | null }[]>([]);
72
+ const [pipelineRuns, setPipelineRuns] = useState<{ id: string; workflowName: string; pipelineId: string; status: string; summary: string; createdAt: string }[]>([]);
73
+ const [availableWorkflows, setAvailableWorkflows] = useState<{ name: string; description?: string; builtin?: boolean }[]>([]);
74
+ const [showAddPipeline, setShowAddPipeline] = useState(false);
75
+ const [triggerInput, setTriggerInput] = useState<Record<string, string>>({});
76
76
  const [claudeMdContent, setClaudeMdContent] = useState('');
77
77
  const [claudeMdExists, setClaudeMdExists] = useState(false);
78
78
  const [claudeTemplates, setClaudeTemplates] = useState<{ id: string; name: string; description: string; tags: string[]; builtin: boolean; content: string }[]>([]);
@@ -220,57 +220,28 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
220
220
  fetchProjectSkills();
221
221
  };
222
222
 
223
- const fetchIssueConfig = useCallback(async () => {
223
+ const fetchPipelineBindings = useCallback(async () => {
224
224
  try {
225
- const res = await fetch(`/api/issue-scanner?project=${encodeURIComponent(projectPath)}`);
225
+ const res = await fetch(`/api/project-pipelines?project=${encodeURIComponent(projectPath)}`);
226
+ if (!res.ok) return;
226
227
  const data = await res.json();
227
- setIssueConfig(data.config || { enabled: false, interval: 30, labels: [], baseBranch: '' });
228
- setIssueProcessed(data.processed || []);
229
- setIssueLastScan(data.lastScan || null);
230
- setIssueNextScan(data.nextScan || null);
228
+ setPipelineBindings(data.bindings || []);
229
+ setPipelineRuns(data.runs || []);
230
+ setAvailableWorkflows(data.workflows || []);
231
231
  } catch {}
232
232
  }, [projectPath]);
233
233
 
234
- const saveIssueConfig = async (config: any) => {
235
- await fetch('/api/issue-scanner', {
236
- method: 'POST',
237
- headers: { 'Content-Type': 'application/json' },
238
- body: JSON.stringify({ action: 'save-config', projectPath, projectName, ...config }),
239
- });
240
- fetchIssueConfig();
241
- };
242
-
243
- const scanNow = async () => {
244
- setIssueScanning(true);
234
+ const triggerProjectPipeline = async (workflowName: string, input?: Record<string, string>) => {
245
235
  try {
246
- const res = await fetch('/api/issue-scanner', {
236
+ const res = await fetch('/api/project-pipelines', {
247
237
  method: 'POST',
248
238
  headers: { 'Content-Type': 'application/json' },
249
- body: JSON.stringify({ action: 'scan', projectPath }),
239
+ body: JSON.stringify({ action: 'trigger', projectPath, projectName, workflowName, input }),
250
240
  });
251
241
  const data = await res.json();
252
- if (data.error) {
253
- alert(data.error);
254
- } else if (data.triggered > 0) {
255
- alert(`Triggered ${data.triggered} issue fix(es): #${data.issues.join(', #')}`);
256
- } else {
257
- alert(`Scanned ${data.total} open issues — no new issues to process`);
258
- }
259
- await fetchIssueConfig();
260
- } catch (e) {
261
- alert('Scan failed');
262
- }
263
- setIssueScanning(false);
264
- };
265
-
266
- const triggerIssue = async (issueId: string) => {
267
- await fetch('/api/issue-scanner', {
268
- method: 'POST',
269
- headers: { 'Content-Type': 'application/json' },
270
- body: JSON.stringify({ action: 'trigger', projectPath, issueId, projectName }),
271
- });
272
- setIssueManualId('');
273
- fetchIssueConfig();
242
+ if (data.ok) { fetchPipelineBindings(); }
243
+ else { alert(data.error || 'Failed'); }
244
+ } catch { alert('Failed to trigger pipeline'); }
274
245
  };
275
246
 
276
247
  const fetchClaudeMd = useCallback(async () => {
@@ -409,9 +380,9 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
409
380
  // Lazy load tab-specific data only when switching to that tab
410
381
  useEffect(() => {
411
382
  if (projectTab === 'skills') fetchProjectSkills();
412
- if (projectTab === 'issues') fetchIssueConfig();
383
+ if (projectTab === 'pipelines') fetchPipelineBindings();
413
384
  if (projectTab === 'claudemd') fetchClaudeMd();
414
- }, [projectTab, fetchProjectSkills, fetchIssueConfig, fetchClaudeMd]);
385
+ }, [projectTab, fetchProjectSkills, fetchPipelineBindings, fetchClaudeMd]);
415
386
 
416
387
  return (
417
388
  <>
@@ -481,13 +452,13 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
481
452
  {claudeMdExists && <span className="ml-1 text-[8px] text-[var(--green)]">•</span>}
482
453
  </button>
483
454
  <button
484
- onClick={() => setProjectTab('issues')}
455
+ onClick={() => setProjectTab('pipelines')}
485
456
  className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
486
- projectTab === 'issues' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
457
+ projectTab === 'pipelines' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
487
458
  }`}
488
459
  >
489
- Issues
490
- {issueConfig?.enabled && <span className="ml-1 text-[8px] text-[var(--green)]">•</span>}
460
+ Pipelines
461
+ {pipelineBindings.length > 0 && <span className="ml-1 text-[8px] text-[var(--text-secondary)]">({pipelineBindings.length})</span>}
491
462
  </button>
492
463
  </div>
493
464
  </div>
@@ -521,12 +492,18 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
521
492
  {/* Code content area */}
522
493
  {projectTab === 'code' && <div className="flex-1 flex min-h-0 overflow-hidden">
523
494
  {/* File tree */}
524
- <div className="w-52 border-r border-[var(--border)] overflow-y-auto p-1 shrink-0">
495
+ <div style={{ width: sidebarWidth }} className="overflow-y-auto p-1 shrink-0">
525
496
  {fileTree.map((node: any) => (
526
497
  <FileTreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} />
527
498
  ))}
528
499
  </div>
529
500
 
501
+ {/* Sidebar resize handle */}
502
+ <div
503
+ onMouseDown={onSidebarDragStart}
504
+ className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50 transition-colors"
505
+ />
506
+
530
507
  {/* File content */}
531
508
  <div className="flex-1 min-w-0 overflow-auto bg-[var(--bg-primary)]" style={{ width: 0 }}>
532
509
  {/* Diff view */}
@@ -574,7 +551,7 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
574
551
  {projectTab === 'skills' && (
575
552
  <div className="flex-1 flex min-h-0 overflow-hidden">
576
553
  {/* Left: skill/command tree */}
577
- <div className="w-52 border-r border-[var(--border)] overflow-y-auto p-1 shrink-0">
554
+ <div style={{ width: sidebarWidth }} className="overflow-y-auto p-1 shrink-0">
578
555
  {projectSkills.length === 0 ? (
579
556
  <p className="text-[9px] text-[var(--text-secondary)] p-2">No skills or commands installed</p>
580
557
  ) : (
@@ -622,6 +599,12 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
622
599
  )}
623
600
  </div>
624
601
 
602
+ {/* Sidebar resize handle */}
603
+ <div
604
+ onMouseDown={onSidebarDragStart}
605
+ className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50 transition-colors"
606
+ />
607
+
625
608
  {/* Right: file content / editor */}
626
609
  <div className="flex-1 min-w-0 flex flex-col overflow-hidden bg-[var(--bg-primary)]">
627
610
  {skillActivePath ? (
@@ -689,7 +672,7 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
689
672
  {projectTab === 'claudemd' && (
690
673
  <div className="flex-1 flex min-h-0 overflow-hidden">
691
674
  {/* Left: templates list */}
692
- <div className="w-52 border-r border-[var(--border)] overflow-y-auto shrink-0 flex flex-col">
675
+ <div style={{ width: sidebarWidth }} className="overflow-y-auto shrink-0 flex flex-col">
693
676
  <button
694
677
  onClick={() => { setClaudeSelectedTemplate(null); setClaudeEditing(false); }}
695
678
  className={`w-full px-2 py-1.5 border-b border-[var(--border)] text-[10px] text-left flex items-center gap-1 ${
@@ -734,6 +717,12 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
734
717
  </div>
735
718
  </div>
736
719
 
720
+ {/* Sidebar resize handle */}
721
+ <div
722
+ onMouseDown={onSidebarDragStart}
723
+ className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50 transition-colors"
724
+ />
725
+
737
726
  {/* Right: CLAUDE.md content or template preview */}
738
727
  <div className="flex-1 min-w-0 flex flex-col overflow-hidden bg-[var(--bg-primary)]">
739
728
  {/* Header bar */}
@@ -797,149 +786,157 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
797
786
  </div>
798
787
  )}
799
788
 
800
- {/* Issues tab */}
801
- {projectTab === 'issues' && issueConfig && (
789
+ {/* Pipelines tab */}
790
+ {projectTab === 'pipelines' && (
802
791
  <div className="flex-1 overflow-auto p-4 space-y-4">
803
- {/* Config */}
792
+ {/* Bound workflows */}
804
793
  <div className="space-y-3">
805
- <div className="flex items-center gap-3">
806
- <label className="flex items-center gap-2 cursor-pointer">
807
- <input
808
- type="checkbox"
809
- checked={issueConfig.enabled}
810
- onChange={e => setIssueConfig({ ...issueConfig, enabled: e.target.checked })}
811
- className="accent-[var(--accent)]"
812
- />
813
- <span className="text-[11px] text-[var(--text-primary)] font-semibold">Enable Issue Auto-fix</span>
814
- </label>
815
- {issueConfig.enabled && (<>
816
- <button
817
- onClick={() => scanNow()}
818
- disabled={issueScanning}
819
- className="text-[9px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white disabled:opacity-50"
820
- >
821
- {issueScanning ? 'Scanning...' : 'Scan Now'}
822
- </button>
823
- {issueLastScan && (
824
- <span className="text-[8px] text-[var(--text-secondary)]">
825
- Last: {new Date(issueLastScan).toLocaleTimeString()}
826
- </span>
827
- )}
828
- {issueNextScan && (
829
- <span className="text-[8px] text-[var(--text-secondary)]">
830
- Next: {new Date(issueNextScan).toLocaleTimeString()}
831
- </span>
832
- )}
833
- </> )}
794
+ <div className="flex items-center gap-2">
795
+ <span className="text-xs font-semibold text-[var(--text-primary)]">Bound Pipelines</span>
796
+ <button
797
+ onClick={() => setShowAddPipeline(v => !v)}
798
+ className="text-[9px] px-2 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90 ml-auto"
799
+ >+ Add</button>
834
800
  </div>
835
801
 
836
- {issueConfig.enabled && (
837
- <div className="grid grid-cols-2 gap-3">
838
- <div>
839
- <label className="text-[9px] text-[var(--text-secondary)] block mb-1">Scan Interval (minutes, 0=manual)</label>
840
- <input
841
- type="number"
842
- value={issueConfig.interval}
843
- onChange={e => setIssueConfig({ ...issueConfig, interval: parseInt(e.target.value) || 0 })}
844
- className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
845
- />
846
- </div>
847
- <div>
848
- <label className="text-[9px] text-[var(--text-secondary)] block mb-1">Base Branch (empty=auto)</label>
849
- <input
850
- type="text"
851
- value={issueConfig.baseBranch}
852
- onChange={e => setIssueConfig({ ...issueConfig, baseBranch: e.target.value })}
853
- placeholder="main"
854
- className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
855
- />
856
- </div>
857
- <div className="col-span-2">
858
- <label className="text-[9px] text-[var(--text-secondary)] block mb-1">Labels Filter (comma-separated, empty=all)</label>
859
- <input
860
- type="text"
861
- value={issueConfig.labels.join(', ')}
862
- onChange={e => setIssueConfig({ ...issueConfig, labels: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })}
863
- placeholder="bug, fix"
864
- className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
865
- />
866
- </div>
802
+ {/* Add pipeline form */}
803
+ {showAddPipeline && (
804
+ <div className="border border-[var(--border)] rounded p-2 space-y-2">
805
+ {availableWorkflows.filter(w => !pipelineBindings.find(b => b.workflowName === w.name)).map(w => (
806
+ <button
807
+ key={w.name}
808
+ onClick={async () => {
809
+ await fetch('/api/project-pipelines', {
810
+ method: 'POST',
811
+ headers: { 'Content-Type': 'application/json' },
812
+ body: JSON.stringify({ action: 'add', projectPath, projectName, workflowName: w.name }),
813
+ });
814
+ setShowAddPipeline(false);
815
+ fetchPipelineBindings();
816
+ }}
817
+ className="w-full text-left px-2 py-1.5 rounded hover:bg-[var(--bg-tertiary)] text-[10px] flex items-center gap-2"
818
+ >
819
+ {w.builtin && <span className="text-[7px] text-[var(--text-secondary)]">⚙</span>}
820
+ <span className="text-[var(--text-primary)]">{w.name}</span>
821
+ {w.description && <span className="text-[var(--text-secondary)] truncate ml-auto text-[8px]">{w.description}</span>}
822
+ </button>
823
+ ))}
824
+ {availableWorkflows.filter(w => !pipelineBindings.find(b => b.workflowName === w.name)).length === 0 && (
825
+ <p className="text-[9px] text-[var(--text-secondary)] p-2">All workflows already bound</p>
826
+ )}
867
827
  </div>
868
828
  )}
869
- <div className="mt-3">
870
- <button
871
- onClick={() => saveIssueConfig(issueConfig)}
872
- className="text-[10px] px-4 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
873
- >Save Configuration</button>
874
- </div>
875
- </div>
876
829
 
877
- {/* Manual trigger */}
878
- <div className="border-t border-[var(--border)] pt-3">
879
- <div className="text-[9px] text-[var(--text-secondary)] uppercase mb-2">Manual Trigger</div>
880
- <div className="flex gap-2">
881
- <input
882
- type="text"
883
- value={issueManualId}
884
- onChange={e => setIssueManualId(e.target.value)}
885
- placeholder="Issue #"
886
- className="w-24 px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
887
- />
888
- <button
889
- onClick={() => issueManualId && triggerIssue(issueManualId)}
890
- disabled={!issueManualId}
891
- className="text-[9px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
892
- >Fix Issue</button>
893
- </div>
830
+ {/* Bound pipeline list */}
831
+ {pipelineBindings.length === 0 ? (
832
+ <p className="text-[10px] text-[var(--text-secondary)]">No pipelines bound. Click + Add to attach a workflow.</p>
833
+ ) : (
834
+ pipelineBindings.map(b => (
835
+ <div key={b.workflowName} className="border border-[var(--border)] rounded p-3 space-y-2">
836
+ <div className="flex items-center gap-2">
837
+ <span className="text-[11px] font-semibold text-[var(--text-primary)]">{b.workflowName}</span>
838
+ <label className="flex items-center gap-1 text-[9px] text-[var(--text-secondary)] cursor-pointer ml-auto">
839
+ <input type="checkbox" checked={b.enabled} onChange={async (e) => {
840
+ await fetch('/api/project-pipelines', {
841
+ method: 'POST',
842
+ headers: { 'Content-Type': 'application/json' },
843
+ body: JSON.stringify({ action: 'update', projectPath, workflowName: b.workflowName, enabled: e.target.checked }),
844
+ });
845
+ fetchPipelineBindings();
846
+ }} className="accent-[var(--accent)]" />
847
+ Enabled
848
+ </label>
849
+ <button
850
+ onClick={() => triggerProjectPipeline(b.workflowName, triggerInput)}
851
+ className="text-[9px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white"
852
+ >Run</button>
853
+ <button
854
+ onClick={async () => {
855
+ if (!confirm(`Remove "${b.workflowName}" from this project?`)) return;
856
+ await fetch('/api/project-pipelines', {
857
+ method: 'POST',
858
+ headers: { 'Content-Type': 'application/json' },
859
+ body: JSON.stringify({ action: 'remove', projectPath, workflowName: b.workflowName }),
860
+ });
861
+ fetchPipelineBindings();
862
+ }}
863
+ className="text-[9px] text-[var(--red)] hover:underline"
864
+ >Remove</button>
865
+ </div>
866
+ {/* Schedule config */}
867
+ <div className="flex items-center gap-2 text-[9px]">
868
+ <span className="text-[var(--text-secondary)]">Schedule:</span>
869
+ <select
870
+ value={b.config.interval || 0}
871
+ onChange={async (e) => {
872
+ const interval = Number(e.target.value);
873
+ const newConfig = { ...b.config, interval };
874
+ await fetch('/api/project-pipelines', {
875
+ method: 'POST',
876
+ headers: { 'Content-Type': 'application/json' },
877
+ body: JSON.stringify({ action: 'update', projectPath, workflowName: b.workflowName, config: newConfig }),
878
+ });
879
+ fetchPipelineBindings();
880
+ }}
881
+ className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded px-1.5 py-0.5 text-[9px] text-[var(--text-primary)]"
882
+ >
883
+ <option value={0}>Manual only</option>
884
+ <option value={15}>Every 15 min</option>
885
+ <option value={30}>Every 30 min</option>
886
+ <option value={60}>Every 1 hour</option>
887
+ <option value={120}>Every 2 hours</option>
888
+ <option value={360}>Every 6 hours</option>
889
+ <option value={720}>Every 12 hours</option>
890
+ <option value={1440}>Every 24 hours</option>
891
+ </select>
892
+ {b.config.interval > 0 && b.nextRunAt && (
893
+ <span className="text-[8px] text-[var(--text-secondary)]">
894
+ Next: {new Date(b.nextRunAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
895
+ </span>
896
+ )}
897
+ {b.lastRunAt && (
898
+ <span className="text-[8px] text-[var(--text-secondary)] ml-auto">
899
+ Last: {new Date(b.lastRunAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
900
+ </span>
901
+ )}
902
+ </div>
903
+ </div>
904
+ ))
905
+ )}
894
906
  </div>
895
907
 
896
- {/* History */}
897
- {issueProcessed.length > 0 && (
908
+ {/* Execution history */}
909
+ {pipelineRuns.length > 0 && (
898
910
  <div className="border-t border-[var(--border)] pt-3">
899
- <div className="text-[9px] text-[var(--text-secondary)] uppercase mb-2">Processed Issues</div>
911
+ <div className="text-[9px] text-[var(--text-secondary)] uppercase mb-2">Execution History</div>
900
912
  <div className="border border-[var(--border)] rounded overflow-hidden">
901
- {issueProcessed.map(p => (
902
- <div key={p.issueNumber} className="border-b border-[var(--border)]/30 last:border-b-0">
903
- <div className="flex items-center gap-2 px-3 py-1.5 text-[10px]">
904
- <span className="text-[var(--text-primary)] font-mono">#{p.issueNumber}</span>
905
- <span className={`text-[8px] px-1 rounded ${
906
- p.status === 'done' ? 'bg-green-500/10 text-green-400' :
907
- p.status === 'failed' ? 'bg-red-500/10 text-red-400' :
908
- 'bg-yellow-500/10 text-yellow-400'
909
- }`}>{p.status}</span>
910
- {p.prNumber && <span className="text-[var(--accent)]">PR #{p.prNumber}</span>}
911
- {p.pipelineId && (
912
- <button
913
- onClick={() => {
914
- const event = new CustomEvent('forge:view-pipeline', { detail: { pipelineId: p.pipelineId } });
915
- window.dispatchEvent(event);
916
- }}
917
- className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--accent)] font-mono"
918
- title="View pipeline"
919
- >{p.pipelineId.slice(0, 8)}</button>
920
- )}
921
- <span className="text-[var(--text-secondary)] text-[8px]">{p.createdAt}</span>
922
- <div className="ml-auto flex gap-1">
923
- {(p.status === 'failed' || p.status === 'done' || p.status === 'processing') && (
924
- <button
925
- onClick={() => setRetryModal({ issueNumber: p.issueNumber, context: '' })}
926
- className="text-[8px] text-[var(--accent)] hover:underline"
927
- >Retry</button>
928
- )}
929
- <button
930
- onClick={async () => {
931
- if (!confirm(`Delete record for issue #${p.issueNumber}?`)) return;
932
- await fetch('/api/issue-scanner', {
933
- method: 'POST',
934
- headers: { 'Content-Type': 'application/json' },
935
- body: JSON.stringify({ action: 'reset', projectPath, issueId: p.issueNumber }),
936
- });
937
- fetchIssueConfig();
938
- }}
939
- className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)]"
940
- >Delete</button>
913
+ {pipelineRuns.map(run => (
914
+ <div key={run.id} className="flex items-start gap-2 px-3 py-2 border-b border-[var(--border)]/30 last:border-b-0 text-[10px]">
915
+ <span className={`shrink-0 ${
916
+ run.status === 'done' ? 'text-green-400' : run.status === 'failed' ? 'text-red-400' : 'text-yellow-400'
917
+ }`}>●</span>
918
+ <div className="flex-1 min-w-0">
919
+ <div className="flex items-center gap-2">
920
+ <span className="text-[var(--text-primary)] font-medium">{run.workflowName}</span>
921
+ <span className="text-[8px] text-[var(--text-secondary)] font-mono">{run.pipelineId.slice(0, 8)}</span>
922
+ <span className="text-[8px] text-[var(--text-secondary)] ml-auto">{new Date(run.createdAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
941
923
  </div>
924
+ {run.summary && (
925
+ <pre className="text-[9px] text-[var(--text-secondary)] mt-1 whitespace-pre-wrap break-words line-clamp-3">{run.summary}</pre>
926
+ )}
942
927
  </div>
928
+ <button
929
+ onClick={async () => {
930
+ if (!confirm('Delete this run?')) return;
931
+ await fetch('/api/project-pipelines', {
932
+ method: 'POST',
933
+ headers: { 'Content-Type': 'application/json' },
934
+ body: JSON.stringify({ action: 'delete-run', id: run.id }),
935
+ });
936
+ fetchPipelineBindings();
937
+ }}
938
+ className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)] shrink-0"
939
+ >×</button>
943
940
  </div>
944
941
  ))}
945
942
  </div>
@@ -1026,50 +1023,6 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
1026
1023
  </div>
1027
1024
  )}
1028
1025
 
1029
- {/* Retry modal */}
1030
- {retryModal && (
1031
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setRetryModal(null)}>
1032
- <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl w-[420px] max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
1033
- <div className="px-4 py-3 border-b border-[var(--border)]">
1034
- <h3 className="text-sm font-semibold text-[var(--text-primary)]">Retry Issue #{retryModal.issueNumber}</h3>
1035
- <p className="text-[10px] text-[var(--text-secondary)] mt-0.5">Add context to help the AI fix the issue better this time.</p>
1036
- </div>
1037
- <div className="p-4">
1038
- <textarea
1039
- value={retryModal.context}
1040
- onChange={e => setRetryModal({ ...retryModal, context: e.target.value })}
1041
- placeholder="e.g. The previous fix caused a merge conflict. Rebase from main first, then fix only the validation logic in src/utils.ts..."
1042
- className="w-full h-32 px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[11px] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]/50 resize-none focus:outline-none focus:border-[var(--accent)]"
1043
- autoFocus
1044
- />
1045
- </div>
1046
- <div className="px-4 py-3 border-t border-[var(--border)] flex justify-end gap-2">
1047
- <button
1048
- onClick={() => setRetryModal(null)}
1049
- className="text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
1050
- >Cancel</button>
1051
- <button
1052
- onClick={async () => {
1053
- await fetch('/api/issue-scanner', {
1054
- method: 'POST',
1055
- headers: { 'Content-Type': 'application/json' },
1056
- body: JSON.stringify({
1057
- action: 'retry',
1058
- projectPath,
1059
- projectName,
1060
- issueId: retryModal.issueNumber,
1061
- context: retryModal.context,
1062
- }),
1063
- });
1064
- setRetryModal(null);
1065
- fetchIssueConfig();
1066
- }}
1067
- className="text-[11px] px-4 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
1068
- >Retry</button>
1069
- </div>
1070
- </div>
1071
- </div>
1072
- )}
1073
1026
  </>
1074
1027
  );
1075
1028
  });
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useEffect, useRef, useCallback } from 'react';
4
+ import { useSidebarResize } from '@/hooks/useSidebarResize';
4
5
  import MarkdownContent from './MarkdownContent';
5
6
 
6
7
  interface SessionEntry {
@@ -42,6 +43,7 @@ export default function SessionView({
42
43
  projects: { name: string; path: string; language: string | null }[];
43
44
  onOpenInTerminal?: (sessionId: string, projectPath: string) => void;
44
45
  }) {
46
+ const { sidebarWidth, onSidebarDragStart } = useSidebarResize({ defaultWidth: 288, minWidth: 160, maxWidth: 480 });
45
47
  // Tree data: project → sessions
46
48
  const [sessionTree, setSessionTree] = useState<Record<string, ClaudeSessionInfo[]>>({});
47
49
  const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set());
@@ -285,7 +287,7 @@ export default function SessionView({
285
287
  return (
286
288
  <div className="flex h-full">
287
289
  {/* Left: tree view */}
288
- <div className="w-72 border-r border-[var(--border)] flex flex-col shrink-0">
290
+ <div style={{ width: sidebarWidth }} className="flex flex-col shrink-0 overflow-hidden">
289
291
  {/* Header */}
290
292
  <div className="flex items-center justify-between p-2 border-b border-[var(--border)]">
291
293
  <span className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase">Sessions</span>
@@ -451,6 +453,12 @@ export default function SessionView({
451
453
  </div>
452
454
  </div>
453
455
 
456
+ {/* Sidebar resize handle */}
457
+ <div
458
+ onMouseDown={onSidebarDragStart}
459
+ className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50 transition-colors"
460
+ />
461
+
454
462
  {/* Right: session content */}
455
463
  <div className="flex-1 flex flex-col min-w-0 overflow-hidden">
456
464
  {activeSession && (
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useEffect, useCallback } from 'react';
4
+ import { useSidebarResize } from '@/hooks/useSidebarResize';
4
5
 
5
6
  type ItemType = 'skill' | 'command';
6
7
 
@@ -28,6 +29,7 @@ interface ProjectInfo {
28
29
  }
29
30
 
30
31
  export default function SkillsPanel({ projectFilter }: { projectFilter?: string }) {
32
+ const { sidebarWidth, onSidebarDragStart } = useSidebarResize({ defaultWidth: 224, minWidth: 140, maxWidth: 400 });
31
33
  const [skills, setSkills] = useState<Skill[]>([]);
32
34
  const [projects, setProjects] = useState<ProjectInfo[]>([]);
33
35
  const [syncing, setSyncing] = useState(false);
@@ -282,7 +284,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
282
284
  ) : (
283
285
  <div className="flex-1 flex min-h-0">
284
286
  {/* Left: skill list */}
285
- <div className="w-56 border-r border-[var(--border)] overflow-y-auto shrink-0">
287
+ <div style={{ width: sidebarWidth }} className="overflow-y-auto shrink-0">
286
288
  {/* Registry items */}
287
289
  {filtered.map(skill => {
288
290
  const isInstalled = skill.installedGlobal || skill.installedProjects.length > 0;
@@ -401,6 +403,12 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
401
403
  )}
402
404
  </div>
403
405
 
406
+ {/* Sidebar resize handle */}
407
+ <div
408
+ onMouseDown={onSidebarDragStart}
409
+ className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50 transition-colors"
410
+ />
411
+
404
412
  {/* Right: detail panel */}
405
413
  <div className="flex-1 flex flex-col min-w-0">
406
414
  {expandedSkill ? (() => {