@pennyfarthing/cyclist 9.2.0 → 9.4.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 (58) hide show
  1. package/dist/api/hotspots.d.ts +3 -0
  2. package/dist/api/hotspots.d.ts.map +1 -0
  3. package/dist/api/hotspots.js +54 -0
  4. package/dist/api/hotspots.js.map +1 -0
  5. package/dist/api/index.d.ts +1 -0
  6. package/dist/api/index.d.ts.map +1 -1
  7. package/dist/api/index.js +1 -0
  8. package/dist/api/index.js.map +1 -1
  9. package/dist/api/settings.d.ts +1 -1
  10. package/dist/api/settings.d.ts.map +1 -1
  11. package/dist/api/settings.js +44 -17
  12. package/dist/api/settings.js.map +1 -1
  13. package/dist/main.d.ts +4 -0
  14. package/dist/main.d.ts.map +1 -1
  15. package/dist/main.js +7 -0
  16. package/dist/main.js.map +1 -1
  17. package/dist/public/css/react.css +1 -1
  18. package/dist/public/js/react/react.js +43 -39
  19. package/dist/server.d.ts.map +1 -1
  20. package/dist/server.js +3 -1
  21. package/dist/server.js.map +1 -1
  22. package/dist/story-parser.d.ts +17 -0
  23. package/dist/story-parser.d.ts.map +1 -1
  24. package/dist/story-parser.js +183 -13
  25. package/dist/story-parser.js.map +1 -1
  26. package/dist/websocket.d.ts.map +1 -1
  27. package/dist/websocket.js +5 -4
  28. package/dist/websocket.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/public/App.tsx +2 -0
  31. package/src/public/components/ControlBar.tsx +1 -1
  32. package/src/public/components/DockviewWorkspace.tsx +4 -0
  33. package/src/public/components/FontPicker/index.tsx +118 -33
  34. package/src/public/components/FullFileTree.tsx +223 -0
  35. package/src/public/components/Message.tsx +32 -10
  36. package/src/public/components/MessageView.tsx +176 -93
  37. package/src/public/components/PersonaHeader.tsx +45 -15
  38. package/src/public/components/SubagentSpan.tsx +15 -8
  39. package/src/public/components/ThemePalette/ThemePalette.css +2 -0
  40. package/src/public/components/ToolStack.tsx +23 -13
  41. package/src/public/components/panels/AuditLogPanel.tsx +140 -66
  42. package/src/public/components/panels/ChangedPanel.tsx +30 -44
  43. package/src/public/components/panels/HotspotsPanel.tsx +365 -0
  44. package/src/public/components/panels/MessagePanel.tsx +14 -2
  45. package/src/public/components/panels/SettingsPanel.tsx +10 -10
  46. package/src/public/components/panels/WorkflowPanel.tsx +85 -12
  47. package/src/public/components/panels/index.ts +1 -0
  48. package/src/public/components/ui/switch.tsx +2 -2
  49. package/src/public/css/theme-system.css +71 -43
  50. package/src/public/hooks/useFileBrowser.ts +71 -0
  51. package/src/public/hooks/useHotspots.ts +113 -0
  52. package/src/public/hooks/useStory.ts +12 -3
  53. package/src/public/images/cyclist-dark.png +0 -0
  54. package/src/public/images/cyclist-light.png +0 -0
  55. package/src/public/styles/tailwind.css +428 -69
  56. package/src/public/types/message.ts +4 -0
  57. package/src/public/utils/slash-commands.ts +1 -1
  58. package/src/public/utils/toolStackGrouper.ts +4 -5
@@ -0,0 +1,223 @@
1
+ /**
2
+ * FullFileTree - Complete directory tree with changed file highlighting
3
+ *
4
+ * Displays the full project file tree with lazy-loaded directories.
5
+ * Changed files are highlighted with git status colors (created/modified/deleted).
6
+ * Uses /api/files for directory listing and /ws/git for change status.
7
+ */
8
+
9
+ import React, { useState, useCallback, useEffect } from 'react';
10
+ import { Badge } from '@/components/ui/badge';
11
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
12
+ import { ScrollArea } from '@/components/ui/scroll-area';
13
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
14
+ import { useFileBrowser, DirectoryEntry } from '../hooks/useFileBrowser';
15
+ import type { FileStatus } from './FileTree';
16
+
17
+ // =============================================================================
18
+ // Types
19
+ // =============================================================================
20
+
21
+ export interface FullFileTreeProps {
22
+ /** Map of file path → git status for highlighting */
23
+ changedFiles: Map<string, FileStatus>;
24
+ /** Callback when a file is clicked */
25
+ onFileClick?: (entry: DirectoryEntry, status?: FileStatus) => void;
26
+ }
27
+
28
+ // =============================================================================
29
+ // File Item Component
30
+ // =============================================================================
31
+
32
+ function TreeFileItem({
33
+ entry,
34
+ status,
35
+ depth,
36
+ onFileClick,
37
+ }: {
38
+ entry: DirectoryEntry;
39
+ status?: FileStatus;
40
+ depth: number;
41
+ onFileClick?: (entry: DirectoryEntry, status?: FileStatus) => void;
42
+ }): React.ReactElement {
43
+ const statusIcon = status === 'created' ? '+' : status === 'modified' ? '~' : status === 'deleted' ? '-' : null;
44
+
45
+ return (
46
+ <Tooltip>
47
+ <TooltipTrigger asChild>
48
+ <div
49
+ role="treeitem"
50
+ className={`file-item${status ? ` file-${status}` : ''}`}
51
+ style={{ paddingLeft: `${12 + depth * 16}px` }}
52
+ tabIndex={0}
53
+ aria-label={`${entry.name}${status ? `, ${status}` : ''}`}
54
+ onClick={() => onFileClick?.(entry, status)}
55
+ onKeyDown={(e) => {
56
+ if (e.key === 'Enter' || e.key === ' ') {
57
+ e.preventDefault();
58
+ onFileClick?.(entry, status);
59
+ }
60
+ }}
61
+ >
62
+ {statusIcon && (
63
+ <span className={`status-icon status-${status}`} aria-hidden="true">
64
+ {statusIcon}
65
+ </span>
66
+ )}
67
+ <span className={`file-name${status === 'deleted' ? '' : ''}`}>{entry.name}</span>
68
+ </div>
69
+ </TooltipTrigger>
70
+ <TooltipContent>{entry.path}</TooltipContent>
71
+ </Tooltip>
72
+ );
73
+ }
74
+
75
+ // =============================================================================
76
+ // Directory Node Component (recursive)
77
+ // =============================================================================
78
+
79
+ function TreeDirectoryNode({
80
+ entry,
81
+ depth,
82
+ changedFiles,
83
+ onFileClick,
84
+ fetchDirectory,
85
+ cache,
86
+ loading,
87
+ }: {
88
+ entry: DirectoryEntry;
89
+ depth: number;
90
+ changedFiles: Map<string, FileStatus>;
91
+ onFileClick?: (entry: DirectoryEntry, status?: FileStatus) => void;
92
+ fetchDirectory: (path: string) => Promise<void>;
93
+ cache: Record<string, DirectoryEntry[]>;
94
+ loading: Set<string>;
95
+ }): React.ReactElement {
96
+ const [isOpen, setIsOpen] = useState(false);
97
+ const children = cache[entry.path];
98
+ const isLoading = loading.has(entry.path);
99
+
100
+ // Check if this directory contains any changed files
101
+ const hasChanges = Array.from(changedFiles.keys()).some(
102
+ (filePath) => filePath.startsWith(entry.path + '/')
103
+ );
104
+
105
+ const handleToggle = useCallback(() => {
106
+ const willOpen = !isOpen;
107
+ setIsOpen(willOpen);
108
+ if (willOpen && !children) {
109
+ fetchDirectory(entry.path);
110
+ }
111
+ }, [isOpen, children, entry.path, fetchDirectory]);
112
+
113
+ return (
114
+ <Collapsible open={isOpen} onOpenChange={handleToggle}>
115
+ <CollapsibleTrigger asChild>
116
+ <div
117
+ className={`directory-header${hasChanges ? ' has-changes' : ''}`}
118
+ style={{ paddingLeft: `${4 + depth * 16}px` }}
119
+ >
120
+ <span className="directory-toggle">
121
+ <span className="toggle-icon">{isOpen ? '▼' : '▶'}</span>
122
+ </span>
123
+ <span className="directory-name">{entry.name}</span>
124
+ </div>
125
+ </CollapsibleTrigger>
126
+ <CollapsibleContent>
127
+ {isLoading && (
128
+ <div
129
+ className="tree-loading"
130
+ style={{ paddingLeft: `${12 + (depth + 1) * 16}px` }}
131
+ >
132
+ Loading...
133
+ </div>
134
+ )}
135
+ {children?.map((child) =>
136
+ child.type === 'directory' ? (
137
+ <TreeDirectoryNode
138
+ key={child.path}
139
+ entry={child}
140
+ depth={depth + 1}
141
+ changedFiles={changedFiles}
142
+ onFileClick={onFileClick}
143
+ fetchDirectory={fetchDirectory}
144
+ cache={cache}
145
+ loading={loading}
146
+ />
147
+ ) : (
148
+ <TreeFileItem
149
+ key={child.path}
150
+ entry={child}
151
+ status={changedFiles.get(child.path)}
152
+ depth={depth + 1}
153
+ onFileClick={onFileClick}
154
+ />
155
+ )
156
+ )}
157
+ </CollapsibleContent>
158
+ </Collapsible>
159
+ );
160
+ }
161
+
162
+ // =============================================================================
163
+ // FullFileTree Component
164
+ // =============================================================================
165
+
166
+ export function FullFileTree({ changedFiles, onFileClick }: FullFileTreeProps): React.ReactElement {
167
+ const { cache, loading, error, fetchDirectory } = useFileBrowser();
168
+
169
+ // Load root directory on mount
170
+ useEffect(() => {
171
+ fetchDirectory('');
172
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
173
+
174
+ const rootEntries = cache['__root__'];
175
+ const changedCount = changedFiles.size;
176
+
177
+ return (
178
+ <TooltipProvider delayDuration={300}>
179
+ <div role="tree" aria-label="Project files" className="filetree full-filetree">
180
+ {changedCount > 0 && (
181
+ <Badge
182
+ variant="secondary"
183
+ data-testid="file-count-badge"
184
+ className="file-count-badge"
185
+ aria-label={`${changedCount} files changed`}
186
+ >
187
+ {changedCount}
188
+ </Badge>
189
+ )}
190
+ <ScrollArea className="filetree-scroll">
191
+ {error && <div className="tree-error">{error}</div>}
192
+ {!rootEntries && !error && (
193
+ <div className="tree-loading">Loading project files...</div>
194
+ )}
195
+ {rootEntries?.map((entry) =>
196
+ entry.type === 'directory' ? (
197
+ <TreeDirectoryNode
198
+ key={entry.path}
199
+ entry={entry}
200
+ depth={0}
201
+ changedFiles={changedFiles}
202
+ onFileClick={onFileClick}
203
+ fetchDirectory={fetchDirectory}
204
+ cache={cache}
205
+ loading={loading}
206
+ />
207
+ ) : (
208
+ <TreeFileItem
209
+ key={entry.path}
210
+ entry={entry}
211
+ status={changedFiles.get(entry.path)}
212
+ depth={0}
213
+ onFileClick={onFileClick}
214
+ />
215
+ )
216
+ )}
217
+ </ScrollArea>
218
+ </div>
219
+ </TooltipProvider>
220
+ );
221
+ }
222
+
223
+ export default FullFileTree;
@@ -17,25 +17,33 @@ import type { MessageData } from '../types/message';
17
17
 
18
18
  interface MessageProps {
19
19
  message: MessageData;
20
+ isLastAgentMessage?: boolean;
21
+ /** Whether this is the first message in a turn (shows avatar) */
22
+ isFirstInTurn?: boolean;
20
23
  }
21
24
 
22
25
  interface AssistantAvatarProps {
23
26
  isStreaming?: boolean;
27
+ agentSlug?: string;
28
+ agentTheme?: string;
29
+ agentCharacter?: string;
24
30
  }
25
31
 
26
- function AssistantAvatar({ isStreaming }: AssistantAvatarProps): React.ReactElement {
32
+ function AssistantAvatar({ isStreaming, agentSlug, agentTheme, agentCharacter }: AssistantAvatarProps): React.ReactElement {
27
33
  const { persona } = usePersona();
28
34
  const [imageError, setImageError] = useState(false);
29
35
 
30
- const slug = persona?.slug;
31
- const theme = persona?.theme;
36
+ // Use per-message persona if available, fall back to current global persona
37
+ const slug = agentSlug || persona?.slug;
38
+ const theme = agentTheme || persona?.theme;
39
+ const character = agentCharacter || persona?.character;
32
40
  const avatarClass = isStreaming ? 'avatar-portrait avatar-thinking' : 'avatar-portrait';
33
41
 
34
42
  if (slug && theme && !imageError) {
35
43
  return (
36
44
  <img
37
45
  src={`/portraits/${theme}/small/${slug}.png`}
38
- alt={persona?.character || 'Agent'}
46
+ alt={character || 'Agent'}
39
47
  className={avatarClass}
40
48
  onError={() => setImageError(true)}
41
49
  />
@@ -67,9 +75,10 @@ function UserAvatar(): React.ReactElement {
67
75
  return <span className="avatar-emoji">👤</span>;
68
76
  }
69
77
 
70
- export default function Message({ message }: MessageProps): React.ReactElement {
78
+ export default function Message({ message, isLastAgentMessage, isFirstInTurn = true }: MessageProps): React.ReactElement {
71
79
  const roleClass = `message-${message.type}`;
72
80
  const testId = `message-${message.type}`;
81
+ const continuationClass = !isFirstInTurn ? ' continuation' : '';
73
82
 
74
83
  // For bell-injected messages (queued messages injected via PostToolUse hook)
75
84
  // Show with 🔔 indicator so user knows it was sent mid-turn
@@ -77,7 +86,7 @@ export default function Message({ message }: MessageProps): React.ReactElement {
77
86
  const html = message.content ? parseMarkdown(message.content) : '';
78
87
  return (
79
88
  <TooltipProvider delayDuration={300}>
80
- <div data-testid="message-bell-injected" className="message message-user message-bell-injected">
89
+ <div data-testid="message-bell-injected" className={`message message-user message-bell-injected${continuationClass}`}>
81
90
  <div data-testid="avatar" className="message-avatar">
82
91
  <UserAvatar />
83
92
  </div>
@@ -106,11 +115,18 @@ export default function Message({ message }: MessageProps): React.ReactElement {
106
115
  }
107
116
 
108
117
  // For streaming agent messages, use StreamingContent with throbbing avatar
118
+ // Only throb the last agent message's avatar
109
119
  if (message.type === 'agent' && message.isStreaming) {
120
+ const showThrob = isLastAgentMessage !== false;
110
121
  return (
111
- <div data-testid={testId} className={`message ${roleClass}`}>
122
+ <div data-testid={testId} className={`message ${roleClass}${continuationClass}`}>
112
123
  <div data-testid="avatar" className="message-avatar">
113
- <AssistantAvatar isStreaming={true} />
124
+ <AssistantAvatar
125
+ isStreaming={showThrob}
126
+ agentSlug={message.agentSlug}
127
+ agentTheme={message.agentTheme}
128
+ agentCharacter={message.agentCharacter}
129
+ />
114
130
  </div>
115
131
  <div className="message-content">
116
132
  <StreamingContent content={message.content || ''} isStreaming={message.isStreaming ?? false} />
@@ -123,9 +139,15 @@ export default function Message({ message }: MessageProps): React.ReactElement {
123
139
  const html = message.content ? parseMarkdown(message.content) : '';
124
140
 
125
141
  return (
126
- <div data-testid={testId} className={`message ${roleClass}`}>
142
+ <div data-testid={testId} className={`message ${roleClass}${continuationClass}`}>
127
143
  <div data-testid="avatar" className="message-avatar">
128
- {message.type === 'user' ? <UserAvatar /> : <AssistantAvatar />}
144
+ {message.type === 'user' ? <UserAvatar /> : (
145
+ <AssistantAvatar
146
+ agentSlug={message.agentSlug}
147
+ agentTheme={message.agentTheme}
148
+ agentCharacter={message.agentCharacter}
149
+ />
150
+ )}
129
151
  </div>
130
152
  <div className="message-content">
131
153
  <div dangerouslySetInnerHTML={{ __html: html }} />