@pennyfarthing/cyclist 10.0.2 → 10.1.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 (102) hide show
  1. package/dist/api/agent-load.d.ts +3 -0
  2. package/dist/api/agent-load.d.ts.map +1 -0
  3. package/dist/api/agent-load.js +124 -0
  4. package/dist/api/agent-load.js.map +1 -0
  5. package/dist/api/code-markers.d.ts +9 -0
  6. package/dist/api/code-markers.d.ts.map +1 -0
  7. package/dist/api/code-markers.js +62 -0
  8. package/dist/api/code-markers.js.map +1 -0
  9. package/dist/api/complexity.d.ts +3 -0
  10. package/dist/api/complexity.d.ts.map +1 -0
  11. package/dist/api/complexity.js +47 -0
  12. package/dist/api/complexity.js.map +1 -0
  13. package/dist/api/dead-code.d.ts +3 -0
  14. package/dist/api/dead-code.d.ts.map +1 -0
  15. package/dist/api/dead-code.js +70 -0
  16. package/dist/api/dead-code.js.map +1 -0
  17. package/dist/api/dependencies.d.ts +3 -0
  18. package/dist/api/dependencies.d.ts.map +1 -0
  19. package/dist/api/dependencies.js +43 -0
  20. package/dist/api/dependencies.js.map +1 -0
  21. package/dist/api/git.d.ts +3 -2
  22. package/dist/api/git.d.ts.map +1 -1
  23. package/dist/api/git.js +11 -6
  24. package/dist/api/git.js.map +1 -1
  25. package/dist/api/health-score.d.ts +3 -0
  26. package/dist/api/health-score.d.ts.map +1 -0
  27. package/dist/api/health-score.js +38 -0
  28. package/dist/api/health-score.js.map +1 -0
  29. package/dist/api/hotspots.d.ts.map +1 -1
  30. package/dist/api/hotspots.js +9 -1
  31. package/dist/api/hotspots.js.map +1 -1
  32. package/dist/api/index.d.ts +6 -0
  33. package/dist/api/index.d.ts.map +1 -1
  34. package/dist/api/index.js +11 -0
  35. package/dist/api/index.js.map +1 -1
  36. package/dist/api/theme-agents.d.ts +1 -0
  37. package/dist/api/theme-agents.d.ts.map +1 -1
  38. package/dist/api/theme-agents.js +1 -1
  39. package/dist/api/theme-agents.js.map +1 -1
  40. package/dist/git-diff.d.ts.map +1 -1
  41. package/dist/git-diff.js +6 -5
  42. package/dist/git-diff.js.map +1 -1
  43. package/dist/main.js +9 -3
  44. package/dist/main.js.map +1 -1
  45. package/dist/preload.js +11 -0
  46. package/dist/preload.js.map +1 -1
  47. package/dist/prime.d.ts +3 -2
  48. package/dist/prime.d.ts.map +1 -1
  49. package/dist/prime.js +25 -8
  50. package/dist/prime.js.map +1 -1
  51. package/dist/public/css/react.css +1 -1
  52. package/dist/public/js/react/react.js +50 -39
  53. package/dist/server.d.ts.map +1 -1
  54. package/dist/server.js +16 -1
  55. package/dist/server.js.map +1 -1
  56. package/dist/sprint-data.d.ts +6 -0
  57. package/dist/sprint-data.d.ts.map +1 -1
  58. package/dist/sprint-data.js +79 -3
  59. package/dist/sprint-data.js.map +1 -1
  60. package/dist/websocket.d.ts.map +1 -1
  61. package/dist/websocket.js +6 -5
  62. package/dist/websocket.js.map +1 -1
  63. package/package.json +32 -31
  64. package/src/public/App.tsx +0 -2
  65. package/src/public/components/AgentLoadDialog.tsx +202 -0
  66. package/src/public/components/ControlBar.tsx +4 -3
  67. package/src/public/components/DeadCodeDialog.tsx +169 -0
  68. package/src/public/components/DockviewWorkspace.tsx +0 -3
  69. package/src/public/components/FullFileTree.tsx +18 -4
  70. package/src/public/components/HealthGauge.tsx +144 -0
  71. package/src/public/components/MessageView.tsx +25 -22
  72. package/src/public/components/SubagentSpan.tsx +2 -2
  73. package/src/public/components/ToolCallBlock.tsx +21 -6
  74. package/src/public/components/dialogs/CodeMarkersDialog.tsx +169 -0
  75. package/src/public/components/dialogs/ComplexityDialog.tsx +163 -0
  76. package/src/public/components/dialogs/DependenciesDialog.tsx +120 -0
  77. package/src/public/components/dialogs/HotspotsDialog.tsx +451 -0
  78. package/src/public/components/dialogs/ToolDialog.tsx +43 -0
  79. package/src/public/components/panels/AcceptanceCriteriaPanel.tsx +15 -30
  80. package/src/public/components/panels/DebugPanel.tsx +83 -0
  81. package/src/public/components/panels/GitPanel.tsx +12 -18
  82. package/src/public/components/panels/SprintPanel.tsx +84 -15
  83. package/src/public/components/panels/index.ts +0 -1
  84. package/src/public/components/ui/dialog.tsx +3 -3
  85. package/src/public/css/theme-system.css +5 -11
  86. package/src/public/hooks/index.ts +4 -0
  87. package/src/public/hooks/useAgentLoad.ts +105 -0
  88. package/src/public/hooks/useCodeMarkers.ts +101 -0
  89. package/src/public/hooks/useColorScheme.ts +25 -10
  90. package/src/public/hooks/useComplexity.ts +80 -0
  91. package/src/public/hooks/useDeadCode.ts +99 -0
  92. package/src/public/hooks/useDependencies.ts +82 -0
  93. package/src/public/hooks/useHealthScore.ts +77 -0
  94. package/src/public/hooks/useHotspots.ts +11 -1
  95. package/src/public/hooks/useSprint.ts +6 -0
  96. package/src/public/styles/tailwind.css +90 -78
  97. package/src/public/utils/messageFilters.ts +77 -6
  98. package/src/public/utils/slash-commands.ts +2 -18
  99. package/src/public/utils/subagent-display.ts +6 -3
  100. package/LICENSE +0 -14
  101. package/src/public/components/AskUserQuestionBlock.tsx +0 -162
  102. package/src/public/utils/askUserQuestion.ts +0 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pennyfarthing/cyclist",
3
- "version": "10.0.2",
3
+ "version": "10.1.0",
4
4
  "description": "Visual terminal interface for Claude Code",
5
5
  "author": "1898andCo",
6
6
  "type": "module",
@@ -19,7 +19,36 @@
19
19
  "src/public/",
20
20
  "portraits/"
21
21
  ],
22
+ "scripts": {
23
+ "dev": "npm run build && concurrently -k -n tsc,preload,vite,electron -c blue,cyan,magenta,green \"tsc --watch --preserveWatchOutput\" \"tsc -p tsconfig.preload.json --watch --preserveWatchOutput\" \"vite build --watch\" \"electron dist/main.js\"",
24
+ "dev:cdp": "npm run build && concurrently -k -n tsc,preload,vite,electron -c blue,cyan,magenta,green \"tsc --watch --preserveWatchOutput\" \"tsc -p tsconfig.preload.json --watch --preserveWatchOutput\" \"vite build --watch\" \"electron --remote-debugging-port=9222 dist/main.js\"",
25
+ "dev:once": "npm run build && electron dist/main.js",
26
+ "dev:web": "CYCLIST_ELECTRON_MODE= CYCLIST_DEV_WEB=1 CYCLIST_PROJECT_DIR=${CYCLIST_PROJECT_DIR:-$PWD} tsx watch src/server.ts",
27
+ "dev:server": "tsx watch src/server.ts",
28
+ "dev:debug": "CYCLIST_DEV_WEB=1 OTEL_DEBUG=true CYCLIST_PROJECT_DIR=${CYCLIST_PROJECT_DIR:-$PWD} node --inspect src/server.ts",
29
+ "dev:debug-brk": "CYCLIST_DEV_WEB=1 OTEL_DEBUG=true CYCLIST_PROJECT_DIR=${CYCLIST_PROJECT_DIR:-$PWD} node --inspect-brk src/server.ts",
30
+ "dev:electron-debug": "npm run build && concurrently -k -n tsc,preload,vite,electron -c blue,cyan,magenta,green \"tsc --watch --preserveWatchOutput\" \"tsc -p tsconfig.preload.json --watch --preserveWatchOutput\" \"vite build --watch\" \"electron --inspect=9229 dist/main.js\"",
31
+ "dev:vite": "vite --config vite.config.ts",
32
+ "build": "npm run build:commands && tsc --build && tsc -p tsconfig.preload.json && npm run build:react",
33
+ "build:react": "vite build",
34
+ "build:commands": "node scripts/generate-slash-commands.js",
35
+ "build:electron": "npm run build && electron-builder -c.npmRebuild=false",
36
+ "start": "node dist/server.js",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest",
39
+ "test:e2e": "playwright test",
40
+ "test:e2e:web": "playwright test --project=web-chromium",
41
+ "test:e2e:ui": "playwright test --ui",
42
+ "test:e2e:debug": "playwright test --debug",
43
+ "test:e2e:trace": "playwright show-trace",
44
+ "prepack": "./scripts/copy-portraits.sh",
45
+ "install:app": "./scripts/install-app.sh",
46
+ "install:cli": "./scripts/install-cli.sh",
47
+ "install:all": "npm run install:app && npm run install:cli"
48
+ },
22
49
  "dependencies": {
50
+ "@pennyfarthing/core": "workspace:^",
51
+ "@pennyfarthing/shared": "workspace:^",
23
52
  "@radix-ui/react-alert-dialog": "^1.1.15",
24
53
  "@radix-ui/react-checkbox": "^1.3.3",
25
54
  "@radix-ui/react-collapsible": "^1.1.12",
@@ -49,9 +78,7 @@
49
78
  "ws": "^8.19.0",
50
79
  "xterm": "^5.3.0",
51
80
  "xterm-addon-fit": "^0.8.0",
52
- "yaml": "^2.8.2",
53
- "@pennyfarthing/core": "^10.0.2",
54
- "@pennyfarthing/shared": "^10.0.2"
81
+ "yaml": "^2.8.2"
55
82
  },
56
83
  "devDependencies": {
57
84
  "@electron/rebuild": "^3.6.0",
@@ -159,31 +186,5 @@
159
186
  ],
160
187
  "category": "Development"
161
188
  }
162
- },
163
- "scripts": {
164
- "dev": "npm run build && concurrently -k -n tsc,preload,vite,electron -c blue,cyan,magenta,green \"tsc --watch --preserveWatchOutput\" \"tsc -p tsconfig.preload.json --watch --preserveWatchOutput\" \"vite build --watch\" \"electron dist/main.js\"",
165
- "dev:cdp": "npm run build && concurrently -k -n tsc,preload,vite,electron -c blue,cyan,magenta,green \"tsc --watch --preserveWatchOutput\" \"tsc -p tsconfig.preload.json --watch --preserveWatchOutput\" \"vite build --watch\" \"electron --remote-debugging-port=9222 dist/main.js\"",
166
- "dev:once": "npm run build && electron dist/main.js",
167
- "dev:web": "CYCLIST_ELECTRON_MODE= CYCLIST_DEV_WEB=1 CYCLIST_PROJECT_DIR=${CYCLIST_PROJECT_DIR:-$PWD} tsx watch src/server.ts",
168
- "dev:server": "tsx watch src/server.ts",
169
- "dev:debug": "CYCLIST_DEV_WEB=1 OTEL_DEBUG=true CYCLIST_PROJECT_DIR=${CYCLIST_PROJECT_DIR:-$PWD} node --inspect src/server.ts",
170
- "dev:debug-brk": "CYCLIST_DEV_WEB=1 OTEL_DEBUG=true CYCLIST_PROJECT_DIR=${CYCLIST_PROJECT_DIR:-$PWD} node --inspect-brk src/server.ts",
171
- "dev:electron-debug": "npm run build && concurrently -k -n tsc,preload,vite,electron -c blue,cyan,magenta,green \"tsc --watch --preserveWatchOutput\" \"tsc -p tsconfig.preload.json --watch --preserveWatchOutput\" \"vite build --watch\" \"electron --inspect=9229 dist/main.js\"",
172
- "dev:vite": "vite --config vite.config.ts",
173
- "build": "npm run build:commands && tsc --build && tsc -p tsconfig.preload.json && npm run build:react",
174
- "build:react": "vite build",
175
- "build:commands": "node scripts/generate-slash-commands.js",
176
- "build:electron": "npm run build && electron-builder -c.npmRebuild=false",
177
- "start": "node dist/server.js",
178
- "test": "vitest run",
179
- "test:watch": "vitest",
180
- "test:e2e": "playwright test",
181
- "test:e2e:web": "playwright test --project=web-chromium",
182
- "test:e2e:ui": "playwright test --ui",
183
- "test:e2e:debug": "playwright test --debug",
184
- "test:e2e:trace": "playwright show-trace",
185
- "install:app": "./scripts/install-app.sh",
186
- "install:cli": "./scripts/install-cli.sh",
187
- "install:all": "npm run install:app && npm run install:cli"
188
189
  }
189
- }
190
+ }
@@ -42,7 +42,6 @@ import {
42
42
  SettingsPanel,
43
43
  AuditLogPanel,
44
44
  TTYPanel,
45
- HotspotsPanel,
46
45
  } from './components/panels';
47
46
 
48
47
  // =============================================================================
@@ -68,7 +67,6 @@ registerPanelComponent(PANEL_INVENTORY.AC, ACPanel);
68
67
  registerPanelComponent(PANEL_INVENTORY.TODO, TodoPanel);
69
68
  registerPanelComponent(PANEL_INVENTORY.BACKGROUND, BackgroundPanel);
70
69
  registerPanelComponent(PANEL_INVENTORY.GIT, GitPanel);
71
- registerPanelComponent(PANEL_INVENTORY.HOTSPOTS, HotspotsPanel);
72
70
  registerPanelComponent(PANEL_INVENTORY.SETTINGS, SettingsPanel);
73
71
 
74
72
  // =============================================================================
@@ -0,0 +1,202 @@
1
+ import React, { useState, useEffect, useMemo } from 'react';
2
+ import { Button } from '@/components/ui/button';
3
+ import { Progress } from '@/components/ui/progress';
4
+ import { Skeleton } from '@/components/ui/skeleton';
5
+ import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
6
+ import { ToolDialog } from './dialogs/ToolDialog';
7
+ import { ConfirmDialog, useConfirmDialog } from './ConfirmDialog';
8
+ import { useAgentLoad } from '../hooks/useAgentLoad.js';
9
+ import type { AgentLoadEntry } from '../hooks/useAgentLoad.js';
10
+ import { formatComponentName } from './panels/DebugPanel';
11
+
12
+ const SIDECAR_FILES = ['patterns.md', 'gotchas.md', 'decisions.md'] as const;
13
+
14
+ export interface AgentLoadDialogProps {
15
+ isOpen: boolean;
16
+ onClose: () => void;
17
+ }
18
+
19
+ export function AgentLoadDialog({ isOpen, onClose }: AgentLoadDialogProps): React.ReactElement {
20
+ const { data, isLoading, error, refresh, pruneSidecar, pruneResult } = useAgentLoad();
21
+ const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
22
+ const [pendingPrune, setPendingPrune] = useState<{ agent: string; file: string } | null>(null);
23
+
24
+ const { confirm, dialogProps } = useConfirmDialog({
25
+ title: 'Clear Sidecar',
26
+ message: pendingPrune
27
+ ? `Reset ${pendingPrune.file} for ${pendingPrune.agent}? This will replace the sidecar with its default template.`
28
+ : '',
29
+ confirmLabel: 'Confirm',
30
+ isDanger: true,
31
+ });
32
+
33
+ useEffect(() => {
34
+ if (isOpen) {
35
+ refresh();
36
+ }
37
+ }, [isOpen, refresh]);
38
+
39
+ const sortedAgents = useMemo(() => {
40
+ if (!data) return [];
41
+ return [...data.agents].sort((a, b) => (b.totalTokens ?? 0) - (a.totalTokens ?? 0));
42
+ }, [data]);
43
+
44
+ const maxTokens = useMemo(() => {
45
+ if (!sortedAgents.length) return 0;
46
+ return sortedAgents[0]?.totalTokens ?? 0;
47
+ }, [sortedAgents]);
48
+
49
+ const handleRowClick = (agent: string) => {
50
+ setExpandedAgent((prev) => (prev === agent ? null : agent));
51
+ };
52
+
53
+ const handleClear = async (agent: string, file: string) => {
54
+ setPendingPrune({ agent, file });
55
+ const confirmed = await confirm();
56
+ if (confirmed) {
57
+ await pruneSidecar(agent, file);
58
+ }
59
+ setPendingPrune(null);
60
+ };
61
+
62
+ const renderAgentRow = (entry: AgentLoadEntry) => {
63
+ const tokens = entry.totalTokens ?? 0;
64
+ const progressValue = maxTokens > 0 ? (tokens / maxTokens) * 100 : 0;
65
+ const isExpanded = expandedAgent === entry.agent;
66
+ // Color by token threshold: <3k green, <5k orange, >=5k red
67
+ const barColor = tokens >= 5000
68
+ ? 'bg-[var(--status-error,#f14c4c)]'
69
+ : tokens >= 3000
70
+ ? 'bg-[var(--status-warning,#cca700)]'
71
+ : 'bg-[var(--status-success,#4ec9b0)]';
72
+
73
+ return (
74
+ <Collapsible
75
+ key={entry.agent}
76
+ open={isExpanded}
77
+ onOpenChange={() => handleRowClick(entry.agent)}
78
+ >
79
+ <CollapsibleTrigger asChild>
80
+ <div
81
+ className="px-4 py-2 cursor-pointer hover:bg-muted/50 rounded-md transition-colors"
82
+ data-testid={`agent-row-${entry.agent}`}
83
+ >
84
+ <div className="flex items-center justify-between mb-1">
85
+ <span className="font-mono text-sm font-medium">{entry.agent}</span>
86
+ <span className="font-mono text-sm tabular-nums text-text-secondary">
87
+ {tokens.toLocaleString()}
88
+ </span>
89
+ </div>
90
+ <Progress
91
+ value={progressValue}
92
+ className="h-2.5 bg-[var(--border)]"
93
+ indicatorClassName={barColor}
94
+ />
95
+ </div>
96
+ </CollapsibleTrigger>
97
+ <CollapsibleContent>
98
+ <div className="mx-4 mb-3 rounded-md bg-muted/30 p-3 space-y-1">
99
+ {entry.components && entry.components.length > 0 ? (
100
+ entry.components.map((comp) => (
101
+ <div key={comp.name} className="flex items-center justify-between text-xs text-text-secondary">
102
+ <span>{formatComponentName(comp.name)}</span>
103
+ <span className="tabular-nums">{comp.tokens.toLocaleString()}</span>
104
+ </div>
105
+ ))
106
+ ) : (
107
+ <div className="text-xs text-text-secondary">No component breakdown available</div>
108
+ )}
109
+ <div className="pt-2 border-t border-muted/50 mt-2">
110
+ <div className="text-xs font-medium mb-1.5 text-text-secondary">Sidecars</div>
111
+ <div className="flex gap-2">
112
+ {SIDECAR_FILES.map((file) => (
113
+ <Button
114
+ key={file}
115
+ variant="ghost"
116
+ size="sm"
117
+ className="text-xs h-6"
118
+ onClick={(e) => {
119
+ e.stopPropagation();
120
+ handleClear(entry.agent, file);
121
+ }}
122
+ >
123
+ Clear {file}
124
+ </Button>
125
+ ))}
126
+ </div>
127
+ </div>
128
+ </div>
129
+ </CollapsibleContent>
130
+ </Collapsible>
131
+ );
132
+ };
133
+
134
+ const renderContent = () => {
135
+ if (isLoading) {
136
+ return (
137
+ <div className="space-y-3 p-4">
138
+ {Array.from({ length: 5 }).map((_, i) => (
139
+ <div key={i} data-testid={`skeleton-${i}`} className="space-y-1.5 px-4">
140
+ <div className="flex justify-between">
141
+ <Skeleton className="h-4 w-24" />
142
+ <Skeleton className="h-4 w-16" />
143
+ </div>
144
+ <Skeleton className="h-2.5 w-full rounded-full" />
145
+ </div>
146
+ ))}
147
+ </div>
148
+ );
149
+ }
150
+
151
+ if (error) {
152
+ return (
153
+ <div className="p-4 space-y-3">
154
+ <div className="p-4 rounded border border-[var(--status-error)]/20 bg-[var(--status-error)]/5 text-[var(--status-error)] text-sm">
155
+ {error.message}
156
+ </div>
157
+ <div className="text-center">
158
+ <Button variant="outline" size="sm" onClick={refresh}>
159
+ Retry
160
+ </Button>
161
+ </div>
162
+ </div>
163
+ );
164
+ }
165
+
166
+ if (!data) return null;
167
+
168
+ return (
169
+ <div className="space-y-0.5">
170
+ {pruneResult?.success && pruneResult.tokensFreed != null && (
171
+ <div className="text-xs text-green-600 px-4 py-1">
172
+ Freed {pruneResult.tokensFreed.toLocaleString()} tokens from {pruneResult.agent}/{pruneResult.file}
173
+ </div>
174
+ )}
175
+ <div className="flex items-center justify-between px-4 py-2">
176
+ <span data-testid="cached-at" className="text-xs text-[var(--text-muted)]">
177
+ Cached: {new Date(data.cachedAt).toLocaleString()}
178
+ </span>
179
+ <Button variant="ghost" size="sm" className="text-xs h-6 text-[var(--text-muted)] hover:text-[var(--text-primary)]" onClick={refresh}>
180
+ Refresh
181
+ </Button>
182
+ </div>
183
+ {sortedAgents.map((entry) => renderAgentRow(entry))}
184
+ </div>
185
+ );
186
+ };
187
+
188
+ return (
189
+ <>
190
+ <ToolDialog
191
+ open={isOpen}
192
+ onOpenChange={(open) => { if (!open) onClose(); }}
193
+ title="Agent Load Analysis"
194
+ description="Token usage breakdown for all agents"
195
+ className="max-w-2xl"
196
+ >
197
+ {renderContent()}
198
+ </ToolDialog>
199
+ <ConfirmDialog {...dialogProps} />
200
+ </>
201
+ );
202
+ }
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import React, { useEffect, useRef, useCallback, useState, FocusEvent } from 'react';
19
+ import { BellRing, Zap, RotateCcw } from 'lucide-react';
19
20
  import { Button } from '@/components/ui/button';
20
21
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
21
22
  import { useClaudeContext } from '../contexts/ClaudeContext';
@@ -149,7 +150,7 @@ export function ControlBar({
149
150
  aria-pressed={bellMode}
150
151
  aria-label="Bell mode - inject queued messages via hook"
151
152
  >
152
- <span className="toggle-icon">🔔</span>
153
+ <BellRing className="h-4 w-4" />
153
154
  </Button>
154
155
  </TooltipTrigger>
155
156
  <TooltipContent>Bell Mode: Inject queued messages during tool use (Cmd+B)</TooltipContent>
@@ -168,7 +169,7 @@ export function ControlBar({
168
169
  aria-pressed={relayMode}
169
170
  aria-label="Relay mode - auto-handoff to next agent"
170
171
  >
171
- <span className="toggle-icon">✋</span>
172
+ <Zap className="h-4 w-4" />
172
173
  </Button>
173
174
  </TooltipTrigger>
174
175
  <TooltipContent>Relay Mode: Auto-handoff to next agent (Cmd+4)</TooltipContent>
@@ -187,7 +188,7 @@ export function ControlBar({
187
188
  disabled={!currentAgent}
188
189
  aria-label="TirePump: Clear context and reload agent"
189
190
  >
190
- <span className="toggle-icon">⬆️</span>
191
+ <RotateCcw className="h-4 w-4" />
191
192
  </Button>
192
193
  </TooltipTrigger>
193
194
  <TooltipContent>{currentAgent ? `TirePump: Clear context (${contextPercent}%) and reload ${currentAgent}` : 'TirePump: No agent loaded'}</TooltipContent>
@@ -0,0 +1,169 @@
1
+ import React, { useState, useMemo, useCallback, useEffect } from 'react';
2
+ import {
3
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
4
+ } from '@/components/ui/dialog';
5
+ import { Badge } from '@/components/ui/badge';
6
+ import { ScrollArea } from '@/components/ui/scroll-area';
7
+ import { cn } from '@/lib/utils';
8
+ import { useDeadCode, type StaleFile, type UnusedExport } from '@/hooks/useDeadCode';
9
+
10
+ export interface DeadCodeDialogProps {
11
+ isOpen: boolean;
12
+ onClose: () => void;
13
+ days?: number;
14
+ repo?: string;
15
+ }
16
+
17
+ type TabId = 'stale' | 'exports';
18
+ type SortDir = 'asc' | 'desc';
19
+
20
+ export function DeadCodeDialog({ isOpen, onClose, days = 180, repo }: DeadCodeDialogProps) {
21
+ const { data, isLoading, error, refresh } = useDeadCode({ days, repo, layer: 'all' });
22
+ const [activeTab, setActiveTab] = useState<TabId>('stale');
23
+ const [staleSortField, setStaleSortField] = useState<keyof StaleFile>('days_since_last_commit');
24
+ const [staleSortDir, setStaleSortDir] = useState<SortDir>('desc');
25
+ const [exportSortField, setExportSortField] = useState<keyof UnusedExport>('file');
26
+ const [exportSortDir, setExportSortDir] = useState<SortDir>('asc');
27
+
28
+ useEffect(() => {
29
+ if (isOpen) refresh();
30
+ }, [isOpen, refresh]);
31
+
32
+ const handleStaleSort = useCallback((field: keyof StaleFile) => {
33
+ setStaleSortDir(prev => staleSortField === field && prev === 'desc' ? 'asc' : 'desc');
34
+ setStaleSortField(field);
35
+ }, [staleSortField]);
36
+
37
+ const handleExportSort = useCallback((field: keyof UnusedExport) => {
38
+ setExportSortDir(prev => exportSortField === field && prev === 'desc' ? 'asc' : 'desc');
39
+ setExportSortField(field);
40
+ }, [exportSortField]);
41
+
42
+ const staleCount = data?.stale_file_count ?? data?.stale_files?.length ?? 0;
43
+ const exportCount = data?.unused_export_count ?? data?.unused_exports?.length ?? 0;
44
+
45
+ const sortedStaleFiles = useMemo(() => {
46
+ const files = [...(data?.stale_files || [])];
47
+ files.sort((a, b) => {
48
+ const aVal = a[staleSortField];
49
+ const bVal = b[staleSortField];
50
+ if (typeof aVal === 'number' && typeof bVal === 'number') {
51
+ return staleSortDir === 'asc' ? aVal - bVal : bVal - aVal;
52
+ }
53
+ const aStr = String(aVal);
54
+ const bStr = String(bVal);
55
+ return staleSortDir === 'asc' ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr);
56
+ });
57
+ return files;
58
+ }, [data?.stale_files, staleSortField, staleSortDir]);
59
+
60
+ const sortedExports = useMemo(() => {
61
+ const exports = [...(data?.unused_exports || [])];
62
+ exports.sort((a, b) => {
63
+ const aVal = a[exportSortField];
64
+ const bVal = b[exportSortField];
65
+ if (typeof aVal === 'number' && typeof bVal === 'number') {
66
+ return exportSortDir === 'asc' ? aVal - bVal : bVal - aVal;
67
+ }
68
+ const aStr = String(aVal);
69
+ const bStr = String(bVal);
70
+ return exportSortDir === 'asc' ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr);
71
+ });
72
+ return exports;
73
+ }, [data?.unused_exports, exportSortField, exportSortDir]);
74
+
75
+ return (
76
+ <Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
77
+ <DialogContent className="max-w-4xl max-h-[80vh]">
78
+ <DialogHeader>
79
+ <DialogTitle>Dead Code Analysis</DialogTitle>
80
+ <DialogDescription>
81
+ Diagnostic report — files and exports with no recent activity.
82
+ </DialogDescription>
83
+ </DialogHeader>
84
+
85
+ {/* Tab bar */}
86
+ <div className="flex gap-4 border-b border-[var(--border)]">
87
+ <button
88
+ onClick={() => setActiveTab('stale')}
89
+ className={cn('pb-2 text-sm', activeTab === 'stale' ? 'border-b-2 border-[var(--accent)] text-[var(--text-primary)] font-medium' : 'text-[var(--text-muted)]')}
90
+ >
91
+ Stale Files <Badge variant="secondary" className="ml-1">{staleCount}</Badge>
92
+ </button>
93
+ <button
94
+ onClick={() => setActiveTab('exports')}
95
+ className={cn('pb-2 text-sm', activeTab === 'exports' ? 'border-b-2 border-[var(--accent)] text-[var(--text-primary)] font-medium' : 'text-[var(--text-muted)]')}
96
+ >
97
+ Unused Exports <Badge variant="secondary" className="ml-1">{exportCount}</Badge>
98
+ </button>
99
+ </div>
100
+
101
+ {/* Content area */}
102
+ <ScrollArea className="h-[50vh]">
103
+ {isLoading && <div className="text-center py-12 text-[var(--text-muted)]">Analyzing...</div>}
104
+ {error && (
105
+ <div className="m-4 p-4 rounded border border-[var(--status-error)]/20 bg-[var(--status-error)]/5 text-[var(--status-error)] text-sm">
106
+ {error.message}
107
+ </div>
108
+ )}
109
+ {data && activeTab === 'stale' && (
110
+ sortedStaleFiles.length === 0 ? (
111
+ <div className="text-center py-12 text-[var(--text-muted)]">No stale files found</div>
112
+ ) : (
113
+ <table className="w-full text-sm">
114
+ <thead>
115
+ <tr role="row" className="border-b border-[var(--border)]">
116
+ <th className="text-left pb-2 cursor-pointer select-none text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]" onClick={() => handleStaleSort('path')}>File</th>
117
+ <th className="text-right pb-2 cursor-pointer select-none text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]" onClick={() => handleStaleSort('days_since_last_commit')}>Days Stale</th>
118
+ <th className="text-right pb-2 cursor-pointer select-none text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]" onClick={() => handleStaleSort('size_bytes')}>Size</th>
119
+ <th className="text-left pb-2 cursor-pointer select-none text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]" onClick={() => handleStaleSort('last_commit_date')}>Last Commit</th>
120
+ </tr>
121
+ </thead>
122
+ <tbody>
123
+ {sortedStaleFiles.map((file) => (
124
+ <tr key={file.path} role="row" className="text-[var(--text-primary)]">
125
+ <td className="py-1.5 font-mono text-xs">{file.path}</td>
126
+ <td className="py-1.5 text-right tabular-nums font-mono">{file.days_since_last_commit}</td>
127
+ <td className="py-1.5 text-right tabular-nums font-mono">{formatBytes(file.size_bytes)}</td>
128
+ <td className="py-1.5">{file.last_commit_date ? new Date(file.last_commit_date).toLocaleDateString() : '—'}</td>
129
+ </tr>
130
+ ))}
131
+ </tbody>
132
+ </table>
133
+ )
134
+ )}
135
+ {data && activeTab === 'exports' && (
136
+ sortedExports.length === 0 ? (
137
+ <div className="text-center py-12 text-[var(--text-muted)]">No unused exports found</div>
138
+ ) : (
139
+ <table className="w-full text-sm">
140
+ <thead>
141
+ <tr role="row" className="border-b border-[var(--border)]">
142
+ <th className="text-left pb-2 cursor-pointer select-none text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]" onClick={() => handleExportSort('file')}>File</th>
143
+ <th className="text-left pb-2 cursor-pointer select-none text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]" onClick={() => handleExportSort('symbol')}>Export</th>
144
+ <th className="text-right pb-2 cursor-pointer select-none text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]" onClick={() => handleExportSort('line')}>Line</th>
145
+ </tr>
146
+ </thead>
147
+ <tbody>
148
+ {sortedExports.map((exp) => (
149
+ <tr key={`${exp.file}:${exp.symbol}`} role="row" className="text-[var(--text-primary)]">
150
+ <td className="py-1.5 font-mono text-xs">{exp.file}</td>
151
+ <td className="py-1.5">{exp.symbol}</td>
152
+ <td className="py-1.5 text-right tabular-nums font-mono">{exp.line}</td>
153
+ </tr>
154
+ ))}
155
+ </tbody>
156
+ </table>
157
+ )
158
+ )}
159
+ </ScrollArea>
160
+ </DialogContent>
161
+ </Dialog>
162
+ );
163
+ }
164
+
165
+ function formatBytes(bytes: number): string {
166
+ if (bytes < 1024) return `${bytes} B`;
167
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
168
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
169
+ }
@@ -49,7 +49,6 @@ export const PANEL_INVENTORY = {
49
49
  TODO: 'todo',
50
50
  BACKGROUND: 'background',
51
51
  GIT: 'git',
52
- HOTSPOTS: 'hotspots',
53
52
  SETTINGS: 'settings',
54
53
  } as const;
55
54
 
@@ -91,7 +90,6 @@ export const RIGHT_SIDEBAR_PANELS = [
91
90
  PANEL_INVENTORY.TODO,
92
91
  PANEL_INVENTORY.BACKGROUND,
93
92
  PANEL_INVENTORY.GIT,
94
- PANEL_INVENTORY.HOTSPOTS,
95
93
  PANEL_INVENTORY.SETTINGS,
96
94
  ] as const;
97
95
 
@@ -109,7 +107,6 @@ const PANEL_TITLES: Record<string, string> = {
109
107
  todo: 'Todo',
110
108
  background: 'Subagents',
111
109
  git: 'Git',
112
- hotspots: 'Hotspots',
113
110
  settings: 'Settings',
114
111
  };
115
112
 
@@ -93,15 +93,29 @@ function TreeDirectoryNode({
93
93
  cache: Record<string, DirectoryEntry[]>;
94
94
  loading: Set<string>;
95
95
  }): React.ReactElement {
96
- const [isOpen, setIsOpen] = useState(false);
97
- const children = cache[entry.path];
98
- const isLoading = loading.has(entry.path);
99
-
100
96
  // Check if this directory contains any changed files
101
97
  const hasChanges = Array.from(changedFiles.keys()).some(
102
98
  (filePath) => filePath.startsWith(entry.path + '/')
103
99
  );
104
100
 
101
+ const [isOpen, setIsOpen] = useState(hasChanges);
102
+ const children = cache[entry.path];
103
+ const isLoading = loading.has(entry.path);
104
+
105
+ // Auto-fetch children when directory has changes and is opened by default
106
+ useEffect(() => {
107
+ if (hasChanges && !children && !loading.has(entry.path)) {
108
+ fetchDirectory(entry.path);
109
+ }
110
+ }, [hasChanges, children, entry.path, fetchDirectory, loading]);
111
+
112
+ // Auto-open when changes appear in this directory
113
+ useEffect(() => {
114
+ if (hasChanges) {
115
+ setIsOpen(true);
116
+ }
117
+ }, [hasChanges]);
118
+
105
119
  const handleToggle = useCallback(() => {
106
120
  const willOpen = !isOpen;
107
121
  setIsOpen(willOpen);