@assistkick/create 1.6.0 → 1.7.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 (38) hide show
  1. package/dist/bin/create.js +0 -0
  2. package/package.json +7 -9
  3. package/templates/assistkick-product-system/packages/frontend/index.html +3 -0
  4. package/templates/assistkick-product-system/packages/frontend/package.json +5 -1
  5. package/templates/assistkick-product-system/packages/frontend/src/App.tsx +16 -7
  6. package/templates/assistkick-product-system/packages/frontend/src/components/DesignSystemView.tsx +363 -0
  7. package/templates/assistkick-product-system/packages/frontend/src/components/GitRepoModal.tsx +6 -8
  8. package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +92 -188
  9. package/templates/assistkick-product-system/packages/frontend/src/components/QaIssueSheet.tsx +11 -20
  10. package/templates/assistkick-product-system/packages/frontend/src/components/SidePanel.tsx +15 -70
  11. package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +149 -77
  12. package/templates/assistkick-product-system/packages/frontend/src/components/ds/KanbanCard.tsx +254 -0
  13. package/templates/assistkick-product-system/packages/frontend/src/components/ds/KanbanCardShowcase.tsx +216 -0
  14. package/templates/assistkick-product-system/packages/frontend/src/components/ds/NavBarSidekick.tsx +163 -0
  15. package/templates/assistkick-product-system/packages/frontend/src/hooks/useGraph.ts +6 -21
  16. package/templates/assistkick-product-system/packages/frontend/src/hooks/useProjects.ts +15 -80
  17. package/templates/assistkick-product-system/packages/frontend/src/routes/CoherenceRoute.tsx +19 -0
  18. package/templates/assistkick-product-system/packages/frontend/src/routes/DashboardLayout.tsx +54 -0
  19. package/templates/assistkick-product-system/packages/frontend/src/routes/DesignSystemRoute.tsx +6 -0
  20. package/templates/assistkick-product-system/packages/frontend/src/routes/GraphRoute.tsx +93 -0
  21. package/templates/assistkick-product-system/packages/frontend/src/routes/KanbanRoute.tsx +30 -0
  22. package/templates/assistkick-product-system/packages/frontend/src/routes/TerminalRoute.tsx +9 -0
  23. package/templates/assistkick-product-system/packages/frontend/src/routes/UsersRoute.tsx +6 -0
  24. package/templates/assistkick-product-system/packages/frontend/src/stores/useGitModalStore.ts +14 -0
  25. package/templates/assistkick-product-system/packages/frontend/src/stores/useGraphStore.ts +36 -0
  26. package/templates/assistkick-product-system/packages/frontend/src/stores/useGraphUIStore.ts +25 -0
  27. package/templates/assistkick-product-system/packages/frontend/src/stores/useProjectStore.ts +87 -0
  28. package/templates/assistkick-product-system/packages/frontend/src/stores/useQaSheetStore.ts +27 -0
  29. package/templates/assistkick-product-system/packages/frontend/src/stores/useSidePanelStore.ts +76 -0
  30. package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +64 -100
  31. package/templates/assistkick-product-system/packages/frontend/vite.config.ts +2 -1
  32. package/templates/assistkick-product-system/packages/shared/lib/graph.ts +11 -5
  33. package/templates/skills/assistkick-bootstrap/SKILL.md +3 -3
  34. package/templates/skills/assistkick-code-reviewer/SKILL.md +2 -2
  35. package/templates/skills/assistkick-debugger/SKILL.md +2 -2
  36. package/templates/skills/assistkick-developer/SKILL.md +3 -3
  37. package/templates/skills/assistkick-interview/SKILL.md +2 -2
  38. package/templates/assistkick-product-system/packages/frontend/package-lock.json +0 -2666
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
2
2
  import { apiClient } from '../api/client';
3
3
  import { COLUMNS, PIPELINE_STATUS_LABELS } from '../constants/graph';
4
4
  import { useToast } from '../hooks/useToast';
5
+ import { KanbanCard } from './ds/KanbanCard';
5
6
 
6
7
 
7
8
  interface KanbanViewProps {
@@ -344,38 +345,50 @@ export function KanbanView({ graphData, projectId, onCardClick, onIssuesClick }:
344
345
  }
345
346
  };
346
347
 
347
- if (error) return <div className="error-msg">{error}</div>;
348
- if (!kanbanData) return <div className="kanban-loading">Loading...</div>;
348
+ if (error) return <div className="p-4 text-error text-[13px]">{error}</div>;
349
+ if (!kanbanData) return <div className="p-4 text-content-muted text-[13px]">Loading...</div>;
349
350
 
350
351
  return (
351
- <div className="kanban-board">
352
+ <div className="flex h-full w-full gap-2 overflow-x-auto">
352
353
  {COLUMNS.map(col => {
353
354
  const cards = getCardsForColumn(col.id);
354
355
  const sourceColumn = draggedCard ? kanbanData?.[draggedCard]?.column : null;
355
- const isDropTarget = draggedCard && col.id !== sourceColumn;
356
356
  const isDragOver = dragOverColumn === col.id;
357
357
 
358
358
  return (
359
- <div key={col.id} className="kanban-column" data-column={col.id}>
360
- <div className="kanban-column-header">
361
- <div className="kanban-column-header-left">
362
- <span className="kanban-column-title">{col.label}</span>
363
- <span className="kanban-column-count">{cards.length}</span>
359
+ <div key={col.id} className="flex shrink-0 basis-[calc(20%-0.4rem)] flex-col rounded-xl border border-edge bg-surface-alt overflow-hidden" data-column={col.id}>
360
+ {/* Column header */}
361
+ <div className="flex items-center justify-between px-3 py-2.5 border-b border-edge bg-surface-raised">
362
+ <div className="flex items-center gap-2">
363
+ <span className="text-[12px] font-bold uppercase tracking-wider text-content">{col.label}</span>
364
+ <span className="rounded-md bg-surface px-1.5 py-0.5 text-[11px] font-mono text-content-muted">{cards.length}</span>
364
365
  </div>
365
366
  {col.id === 'todo' && (
366
367
  playAllRunning ? (
367
- <button className="kanban-play-all-btn stop" title="Stop processing" onClick={stopPlayAll}>
368
+ <button
369
+ className="flex h-6 w-6 items-center justify-center rounded-full border border-error text-error text-[12px] hover:bg-error hover:text-surface transition-colors cursor-pointer"
370
+ title="Stop processing"
371
+ onClick={stopPlayAll}
372
+ >
368
373
  {'\u25A0'}
369
374
  </button>
370
375
  ) : (
371
- <button className="kanban-play-all-btn" title="Start automated development for all TODO features" onClick={startPlayAll}>
376
+ <button
377
+ className="flex h-6 w-6 items-center justify-center rounded-full border border-accent text-accent text-[9px] tracking-[-2px] pl-0.5 hover:bg-accent hover:text-surface transition-colors cursor-pointer"
378
+ title="Start automated development for all TODO features"
379
+ onClick={startPlayAll}
380
+ >
372
381
  {'\u25B6\u25B6'}
373
382
  </button>
374
383
  )
375
384
  )}
376
385
  </div>
386
+ {/* Column body */}
377
387
  <div
378
- className={`kanban-column-body${isDropTarget ? ' drop-target' : ''}${isDragOver ? ' drag-over' : ''}`}
388
+ className={[
389
+ 'flex flex-1 flex-col gap-2 overflow-y-auto p-2',
390
+ isDragOver ? 'bg-accent/10' : '',
391
+ ].join(' ')}
379
392
  data-column={col.id}
380
393
  onDragOver={(e) => handleDragOver(e, col.id)}
381
394
  onDragLeave={handleDragLeave}
@@ -385,192 +398,83 @@ export function KanbanView({ graphData, projectId, onCardClick, onIssuesClick }:
385
398
  const pct = Math.round((card.completeness || 0) * 100);
386
399
  const pStatus = pipelineStatuses[card.id];
387
400
  const isActive = pStatus && !['idle', 'completed', 'blocked', 'failed', 'interrupted'].includes(pStatus.status);
388
- const isTerminal = pStatus && ['completed', 'failed', 'blocked', 'interrupted'].includes(pStatus.status);
389
401
  const pipelineStatusValue = pStatus?.status ?? 'idle';
390
402
  const showResumeBtn = ['in_progress', 'in_review'].includes(card.column)
391
403
  && !isActive
392
404
  && ['interrupted', 'failed', 'idle'].includes(pipelineStatusValue);
393
405
 
406
+ // Build tool calls + meta from stage stats or flat toolCalls
407
+ let toolCalls: Record<string, number> | undefined;
408
+ let metaPills: string[] | undefined;
409
+ let stopReason: string | undefined;
410
+
411
+ const ss = pStatus?.stageStats;
412
+ const availableTabs = ss
413
+ ? STAGE_TABS.filter(t => hasStageData(ss[t.key]))
414
+ : [];
415
+
416
+ if (availableTabs.length > 0) {
417
+ const currentTab = activeStageTab[card.id] || availableTabs[availableTabs.length - 1].key;
418
+ const stage = ss[currentTab];
419
+ const tc = stage?.toolCalls;
420
+ if (tc && tc.total > 0) {
421
+ toolCalls = {};
422
+ if (tc.read > 0) toolCalls['Read'] = tc.read;
423
+ if (tc.bash > 0) toolCalls['Tools'] = tc.bash;
424
+ if (tc.write > 0) toolCalls['Write'] = tc.write;
425
+ if (tc.edit > 0) toolCalls['Edit'] = tc.edit;
426
+ }
427
+ const pills: string[] = [];
428
+ if (stage?.lastTurnUsage) pills.push(`Ctx ${formatContextFill(stage.lastTurnUsage, stage.contextWindow)}`);
429
+ if (stage?.numTurns != null) pills.push(`${stage.numTurns}t`);
430
+ if (stage?.costUsd != null) pills.push(formatCost(stage.costUsd));
431
+ if (stage?.model) pills.push(formatModel(stage.model));
432
+ if (pills.length > 0) metaPills = pills;
433
+ if (stage?.stopReason) stopReason = stage.stopReason;
434
+ } else if (pStatus?.toolCalls?.total > 0) {
435
+ toolCalls = {};
436
+ if (pStatus.toolCalls.read > 0) toolCalls['Read'] = pStatus.toolCalls.read;
437
+ if (pStatus.toolCalls.bash > 0) toolCalls['Tools'] = pStatus.toolCalls.bash;
438
+ if (pStatus.toolCalls.write > 0) toolCalls['Write'] = pStatus.toolCalls.write;
439
+ if (pStatus.toolCalls.edit > 0) toolCalls['Edit'] = pStatus.toolCalls.edit;
440
+ }
441
+
442
+ const issueLabel = card.notes.length > 0
443
+ ? `${card.notes.length} issue${card.notes.length !== 1 ? 's' : ''} reported`
444
+ : card.column === 'qa'
445
+ ? '+ Report Issue'
446
+ : 'No issues';
447
+
394
448
  return (
395
- <div
449
+ <KanbanCard
396
450
  key={card.id}
397
- className={`kanban-card${card.rejectionCount >= 3 ? ' problematic' : ''}${card.devBlocked ? ' dev-blocked' : ''}${playAllCurrentFeature === card.id ? ' play-all-active' : ''}`}
398
- data-feature-id={card.id}
451
+ id={card.id}
452
+ name={card.name}
453
+ pct={pct}
454
+ kind={card.kind}
455
+ rejectionCount={card.rejectionCount}
456
+ blocked={card.devBlocked}
457
+ pipeline={pipelineStatusValue}
458
+ pipelineLabel={pStatus && pStatus.status !== 'idle' ? getPipelineBadgeText(pStatus.status, card.rejectionCount) : undefined}
459
+ toolCalls={toolCalls}
460
+ meta={metaPills}
461
+ stopReason={stopReason}
462
+ issueCount={card.notes.length}
463
+ issueLabel={issueLabel}
464
+ showPlay={card.column === 'todo' && !card.devBlocked}
465
+ showResume={showResumeBtn}
466
+ copied={copiedId === card.id}
467
+ playAllActive={playAllCurrentFeature === card.id}
468
+ onClick={() => onCardClick({ id: card.id, name: card.name })}
469
+ onCopy={() => handleCopy(card)}
470
+ onPlay={() => handleStartPipeline(card.id)}
471
+ onResume={() => handleResumePipeline(card.id)}
472
+ onUnblock={() => handleUnblock(card.id)}
473
+ onIssuesClick={() => onIssuesClick(card.id, card.name, card.column, card.notes, card.reviews)}
399
474
  draggable
400
475
  onDragStart={(e) => handleDragStart(e, card.id)}
401
476
  onDragEnd={handleDragEnd}
402
- onClick={(e) => {
403
- if ((e.target as HTMLElement).closest('button, textarea, .kanban-note-form')) return;
404
- onCardClick({ id: card.id, name: card.name });
405
- }}
406
- style={{ cursor: 'pointer' }}
407
- >
408
- <div className="kanban-card-header">
409
- <span className="kanban-card-id">
410
- {card.id}
411
- {card.kind && card.kind !== 'new' && (
412
- <> <span className={`kanban-card-kind kind-${card.kind}`}>{card.kind}</span></>
413
- )}
414
- </span>
415
- <button
416
- className={`kanban-copy-btn${copiedId === card.id ? ' copied' : ''}`}
417
- title="Copy feature ID and name"
418
- onClick={(e) => { e.stopPropagation(); handleCopy(card); }}
419
- >
420
- {copiedId === card.id ? '\u2713' : '\uD83D\uDCCB'}
421
- </button>
422
- <span className="kanban-card-header-right">
423
- {card.rejectionCount > 0 && (
424
- <span className={`kanban-card-rejections${card.rejectionCount >= 3 ? ' high' : ''}`}>
425
- {card.rejectionCount}x rejected
426
- </span>
427
- )}
428
- {card.column === 'todo' && !card.devBlocked && (
429
- <button
430
- className="kanban-play-btn"
431
- title="Start automated development pipeline"
432
- onClick={(e) => { e.stopPropagation(); handleStartPipeline(card.id); }}
433
- >
434
- {'\u25B6'}
435
- </button>
436
- )}
437
- {showResumeBtn && (
438
- <button
439
- className="kanban-play-btn kanban-resume-btn"
440
- title="Resume pipeline from last completed step"
441
- onClick={(e) => { e.stopPropagation(); handleResumePipeline(card.id); }}
442
- >
443
- {'\u25B6'}
444
- </button>
445
- )}
446
- {card.devBlocked && (
447
- <span className="kanban-blocked-badge">Blocked</span>
448
- )}
449
- </span>
450
- </div>
451
-
452
- <div className="kanban-card-name">{card.name}</div>
453
-
454
- <div className="kanban-card-completeness-row">
455
- <span className="kanban-card-completeness-prefix">Spec</span>
456
- <div className="kanban-card-completeness">
457
- <div className="kanban-card-completeness-fill" style={{ width: `${pct}%` }} />
458
- </div>
459
- <span className="kanban-card-completeness-label">{pct}%</span>
460
- </div>
461
-
462
- {pStatus && pStatus.status !== 'idle' && (
463
- <div className={`kanban-pipeline-status${isActive ? ' pipeline-active' : ''}${isTerminal ? ` pipeline-${pStatus.status}` : ''}`}>
464
- {getPipelineBadgeText(pStatus.status, card.rejectionCount)}
465
- </div>
466
- )}
467
-
468
- {(() => {
469
- const ss = pStatus?.stageStats;
470
- const availableTabs = ss
471
- ? STAGE_TABS.filter(t => hasStageData(ss[t.key]))
472
- : [];
473
- // Fall back to flat toolCalls for old data without stageStats
474
- if (availableTabs.length === 0 && pStatus?.toolCalls?.total > 0) {
475
- return (
476
- <div className="kanban-tool-calls">
477
- {[
478
- { label: 'Write', count: pStatus.toolCalls.write },
479
- { label: 'Edit', count: pStatus.toolCalls.edit },
480
- { label: 'Read', count: pStatus.toolCalls.read },
481
- { label: 'Bash', count: pStatus.toolCalls.bash },
482
- ].filter(i => i.count > 0).map(i => (
483
- <span key={i.label} className="kanban-tool-badge">{i.label}: {i.count}</span>
484
- ))}
485
- </div>
486
- );
487
- }
488
- if (availableTabs.length === 0) return null;
489
-
490
- const currentTab = activeStageTab[card.id] || availableTabs[availableTabs.length - 1].key;
491
- const stage = ss[currentTab];
492
- const tc = stage?.toolCalls;
493
-
494
- return (
495
- <div className="kanban-stage-stats" onClick={(e) => e.stopPropagation()}>
496
- <div className="kanban-stage-tabs">
497
- {availableTabs.map(t => (
498
- <button
499
- key={t.key}
500
- className={`kanban-stage-tab${currentTab === t.key ? ' active' : ''}`}
501
- onClick={(e) => {
502
- e.stopPropagation();
503
- setActiveStageTab(prev => ({ ...prev, [card.id]: t.key }));
504
- }}
505
- >
506
- {t.label}
507
- </button>
508
- ))}
509
- </div>
510
- <div className="kanban-stage-body">
511
- {tc && tc.total > 0 && (
512
- <div className="kanban-tool-calls">
513
- {[
514
- { label: 'Write', count: tc.write },
515
- { label: 'Edit', count: tc.edit },
516
- { label: 'Read', count: tc.read },
517
- { label: 'Bash', count: tc.bash },
518
- ].filter(i => i.count > 0).map(i => (
519
- <span key={i.label} className="kanban-tool-badge">{i.label}: {i.count}</span>
520
- ))}
521
- </div>
522
- )}
523
- <div className="kanban-stage-meta">
524
- {stage?.lastTurnUsage && (
525
- <span className="kanban-stage-meta-item" title="Peak context window utilization">
526
- Ctx: {formatContextFill(stage.lastTurnUsage, stage.contextWindow)}
527
- </span>
528
- )}
529
- {stage?.numTurns != null && (
530
- <span className="kanban-stage-meta-item" title="Agentic turns">
531
- {stage.numTurns} turns
532
- </span>
533
- )}
534
- {stage?.costUsd != null && (
535
- <span className="kanban-stage-meta-item" title={`Model: ${stage.model || 'unknown'}`}>
536
- {formatCost(stage.costUsd)}
537
- {stage.model && <span className="kanban-stage-model"> {formatModel(stage.model)}</span>}
538
- </span>
539
- )}
540
- {stage?.stopReason && (
541
- <span className="kanban-stage-meta-item kanban-stop-reason" title="Stop reason">
542
- {stage.stopReason}
543
- </span>
544
- )}
545
- </div>
546
- </div>
547
- </div>
548
- );
549
- })()}
550
-
551
- {card.devBlocked && (
552
- <button
553
- className="kanban-unblock-btn"
554
- onClick={(e) => { e.stopPropagation(); handleUnblock(card.id); }}
555
- >
556
- Unblock
557
- </button>
558
- )}
559
-
560
- <button
561
- className={`kanban-issues-btn${card.notes.length > 0 ? ' has-issues' : ''}`}
562
- onClick={(e) => {
563
- e.stopPropagation();
564
- onIssuesClick(card.id, card.name, card.column, card.notes, card.reviews);
565
- }}
566
- >
567
- {card.notes.length > 0
568
- ? `${card.notes.length} issue${card.notes.length !== 1 ? 's' : ''} reported`
569
- : card.column === 'qa'
570
- ? '+ Report Issue'
571
- : 'No issues'}
572
- </button>
573
- </div>
477
+ />
574
478
  );
575
479
  })}
576
480
  </div>
@@ -1,21 +1,13 @@
1
1
  import React, { useState, useEffect, useRef, useCallback } from 'react';
2
2
  import { apiClient } from '../api/client';
3
+ import { useQaSheetStore } from '../stores/useQaSheetStore';
3
4
 
4
- interface QaIssueSheetProps {
5
- isOpen: boolean;
6
- featureId: string | null;
7
- featureName: string;
8
- column: string;
9
- notes: any[];
10
- reviews: any[];
11
- onClose: () => void;
12
- onNotesChanged: () => void;
13
- }
5
+ export function QaIssueSheet() {
6
+ const {
7
+ isOpen, featureId, featureName, column, notes: initialNotes,
8
+ reviews: initialReviews, close,
9
+ } = useQaSheetStore();
14
10
 
15
- export function QaIssueSheet({
16
- isOpen, featureId, featureName, column, notes: initialNotes, reviews: initialReviews,
17
- onClose, onNotesChanged,
18
- }: QaIssueSheetProps) {
19
11
  const sheetRef = useRef<HTMLDivElement>(null);
20
12
  const [notes, setNotes] = useState<any[]>(initialNotes);
21
13
  const [reviews, setReviews] = useState<any[]>(initialReviews);
@@ -25,7 +17,7 @@ export function QaIssueSheet({
25
17
 
26
18
  const isEditable = column === 'qa';
27
19
 
28
- // Sync when props change
20
+ // Sync when store data changes
29
21
  useEffect(() => {
30
22
  setNotes(initialNotes);
31
23
  setReviews(initialReviews);
@@ -39,12 +31,12 @@ export function QaIssueSheet({
39
31
  const handleClick = (e: MouseEvent) => {
40
32
  if (sheetRef.current && !sheetRef.current.contains(e.target as Node)) {
41
33
  if ((e.target as HTMLElement).closest('.kanban-issues-btn')) return;
42
- onClose();
34
+ close();
43
35
  }
44
36
  };
45
37
  document.addEventListener('click', handleClick);
46
38
  return () => document.removeEventListener('click', handleClick);
47
- }, [isOpen, onClose]);
39
+ }, [isOpen, close]);
48
40
 
49
41
  const refreshNotes = useCallback(async () => {
50
42
  if (!featureId) return;
@@ -55,11 +47,10 @@ export function QaIssueSheet({
55
47
  setNotes(entry.notes || []);
56
48
  setReviews(entry.reviews || []);
57
49
  }
58
- onNotesChanged();
59
50
  } catch (err) {
60
51
  console.error('Failed to refresh notes:', err);
61
52
  }
62
- }, [featureId, onNotesChanged]);
53
+ }, [featureId]);
63
54
 
64
55
  const handleAddNote = async () => {
65
56
  const text = newNoteText.trim();
@@ -108,7 +99,7 @@ export function QaIssueSheet({
108
99
  <div className={`qa-issue-sheet${isOpen ? ' open' : ''}`} ref={sheetRef}>
109
100
  <div className="qa-sheet-header">
110
101
  <span className="qa-sheet-title">{featureId} — {featureName}</span>
111
- <button className="qa-sheet-close" onClick={onClose}>&times;</button>
102
+ <button className="qa-sheet-close" onClick={close}>&times;</button>
112
103
  </div>
113
104
  <div className="qa-sheet-body">
114
105
  <div className="qa-sheet-subtitle">
@@ -1,63 +1,17 @@
1
- import React, { useState, useCallback } from 'react';
1
+ import React from 'react';
2
2
  import { marked } from 'marked';
3
- import { apiClient } from '../api/client';
4
- import { getTaskIcon, getTaskCssClass, shouldShowTaskList } from '../utils/task_status';
3
+ import { useSidePanelStore } from '../stores/useSidePanelStore';
4
+ import { useGraphStore } from '../stores/useGraphStore';
5
+ import { useGraphUIStore } from '../stores/useGraphUIStore';
6
+ import { getTaskCssClass, getTaskIcon, shouldShowTaskList } from '../utils/task_status';
5
7
 
6
- interface WorkSummary {
7
- cycle: number;
8
- filesCreated: string[];
9
- filesUpdated: string[];
10
- filesDeleted: string[];
11
- approach: string;
12
- decisions: string[];
13
- timestamp: string;
14
- }
15
-
16
- interface SidePanelProps {
17
- graphData: any;
18
- onEdgeClick: (neighborId: string) => void;
19
- }
20
-
21
- export function SidePanel({ graphData, onEdgeClick }: SidePanelProps) {
22
- const [isOpen, setIsOpen] = useState(false);
23
- const [node, setNode] = useState<any>(null);
24
- const [content, setContent] = useState('');
25
- const [workSummaries, setWorkSummaries] = useState<WorkSummary[]>([]);
26
- const [expandedSummaries, setExpandedSummaries] = useState(false);
27
- const [tasks, setTasks] = useState<{ total: number; completed: number; items: any[] } | null>(null);
28
- const [expandedTasks, setExpandedTasks] = useState(false);
29
-
30
- const open = useCallback(async (n: any) => {
31
- setNode(n);
32
- setWorkSummaries([]);
33
- setExpandedSummaries(false);
34
- setTasks(null);
35
- setExpandedTasks(false);
36
- try {
37
- const detail = await apiClient.fetchNode(n.id);
38
- setContent(detail.content);
39
- setIsOpen(true);
40
-
41
- // Fetch pipeline status for feature nodes to get work summaries
42
- if (n.type === 'feature' || n.id?.startsWith('feat_')) {
43
- try {
44
- const pStatus = await apiClient.getPipelineStatus(n.id);
45
- if (pStatus?.workSummaries?.length > 0) {
46
- setWorkSummaries(pStatus.workSummaries);
47
- }
48
- if (pStatus?.tasks) {
49
- setTasks(pStatus.tasks);
50
- }
51
- } catch {
52
- // Pipeline status may not exist for all features
53
- }
54
- }
55
- } catch (err) {
56
- console.error('Failed to fetch node:', err);
57
- }
58
- }, []);
59
-
60
- const close = useCallback(() => setIsOpen(false), []);
8
+ export function SidePanel() {
9
+ const {
10
+ isOpen, node, content, workSummaries, expandedSummaries,
11
+ tasks, expandedTasks, close, toggleSummaries, toggleTasks,
12
+ } = useSidePanelStore();
13
+ const graphData = useGraphStore((s) => s.graphData);
14
+ const onEdgeClick = useGraphUIStore((s) => s.onEdgeClick);
61
15
 
62
16
  const renderMarkdown = (md: string) => {
63
17
  const stripped = md.replace(/^---[\s\S]*?---\n*/m, '');
@@ -78,11 +32,6 @@ export function SidePanel({ graphData, onEdgeClick }: SidePanelProps) {
78
32
  return edges;
79
33
  };
80
34
 
81
- // Expose open/close via ref-like approach on the component instance
82
- // We'll use a callback pattern instead
83
- (SidePanel as any).__open = open;
84
- (SidePanel as any).__close = close;
85
-
86
35
  if (!node) return (
87
36
  <div className={`side-panel${isOpen ? ' open' : ''}`} id="side-panel">
88
37
  <div className="panel-header">
@@ -114,7 +63,7 @@ export function SidePanel({ graphData, onEdgeClick }: SidePanelProps) {
114
63
  <div className="panel-task-list">
115
64
  <button
116
65
  className="panel-task-list-toggle"
117
- onClick={() => setExpandedTasks(prev => !prev)}
66
+ onClick={toggleTasks}
118
67
  >
119
68
  <span className={`panel-task-list-chevron${expandedTasks ? ' expanded' : ''}`}>{'\u25B6'}</span>
120
69
  Tasks ({tasks!.completed}/{tasks!.total})
@@ -138,7 +87,7 @@ export function SidePanel({ graphData, onEdgeClick }: SidePanelProps) {
138
87
  <div className="panel-work-summary">
139
88
  <button
140
89
  className="panel-work-summary-toggle"
141
- onClick={() => setExpandedSummaries(prev => !prev)}
90
+ onClick={toggleSummaries}
142
91
  >
143
92
  {expandedSummaries ? '\u25BC' : '\u25B6'} Work Summary ({workSummaries.length} cycle{workSummaries.length !== 1 ? 's' : ''})
144
93
  </button>
@@ -211,7 +160,7 @@ export function SidePanel({ graphData, onEdgeClick }: SidePanelProps) {
211
160
  <li key={i} className="panel-edge-item">
212
161
  <span className="panel-edge-direction">{direction}</span>
213
162
  <span className="panel-edge-relation">{edge.relation.replace(/_/g, ' ')}</span>
214
- <a className="panel-edge-link" href="#" onClick={(e) => { e.preventDefault(); onEdgeClick(edge.neighborId); }}>
163
+ <a className="panel-edge-link" href="#" onClick={(e) => { e.preventDefault(); onEdgeClick?.(edge.neighborId); }}>
215
164
  {name}
216
165
  </a>
217
166
  <span className="panel-edge-id">{edge.neighborId}</span>
@@ -225,7 +174,3 @@ export function SidePanel({ graphData, onEdgeClick }: SidePanelProps) {
225
174
  </div>
226
175
  );
227
176
  }
228
-
229
- // Export the static methods for imperative use
230
- export const openSidePanel = (node: any) => (SidePanel as any).__open?.(node);
231
- export const closeSidePanel = () => (SidePanel as any).__close?.();