@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.
- package/dist/api/hotspots.d.ts +3 -0
- package/dist/api/hotspots.d.ts.map +1 -0
- package/dist/api/hotspots.js +54 -0
- package/dist/api/hotspots.js.map +1 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +1 -0
- package/dist/api/index.js.map +1 -1
- package/dist/api/settings.d.ts +1 -1
- package/dist/api/settings.d.ts.map +1 -1
- package/dist/api/settings.js +44 -17
- package/dist/api/settings.js.map +1 -1
- package/dist/main.d.ts +4 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +7 -0
- package/dist/main.js.map +1 -1
- package/dist/public/css/react.css +1 -1
- package/dist/public/js/react/react.js +43 -39
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -1
- package/dist/server.js.map +1 -1
- package/dist/story-parser.d.ts +17 -0
- package/dist/story-parser.d.ts.map +1 -1
- package/dist/story-parser.js +183 -13
- package/dist/story-parser.js.map +1 -1
- package/dist/websocket.d.ts.map +1 -1
- package/dist/websocket.js +5 -4
- package/dist/websocket.js.map +1 -1
- package/package.json +1 -1
- package/src/public/App.tsx +2 -0
- package/src/public/components/ControlBar.tsx +1 -1
- package/src/public/components/DockviewWorkspace.tsx +4 -0
- package/src/public/components/FontPicker/index.tsx +118 -33
- package/src/public/components/FullFileTree.tsx +223 -0
- package/src/public/components/Message.tsx +32 -10
- package/src/public/components/MessageView.tsx +176 -93
- package/src/public/components/PersonaHeader.tsx +45 -15
- package/src/public/components/SubagentSpan.tsx +15 -8
- package/src/public/components/ThemePalette/ThemePalette.css +2 -0
- package/src/public/components/ToolStack.tsx +23 -13
- package/src/public/components/panels/AuditLogPanel.tsx +140 -66
- package/src/public/components/panels/ChangedPanel.tsx +30 -44
- package/src/public/components/panels/HotspotsPanel.tsx +365 -0
- package/src/public/components/panels/MessagePanel.tsx +14 -2
- package/src/public/components/panels/SettingsPanel.tsx +10 -10
- package/src/public/components/panels/WorkflowPanel.tsx +85 -12
- package/src/public/components/panels/index.ts +1 -0
- package/src/public/components/ui/switch.tsx +2 -2
- package/src/public/css/theme-system.css +71 -43
- package/src/public/hooks/useFileBrowser.ts +71 -0
- package/src/public/hooks/useHotspots.ts +113 -0
- package/src/public/hooks/useStory.ts +12 -3
- package/src/public/images/cyclist-dark.png +0 -0
- package/src/public/images/cyclist-light.png +0 -0
- package/src/public/styles/tailwind.css +428 -69
- package/src/public/types/message.ts +4 -0
- package/src/public/utils/slash-commands.ts +1 -1
- 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
|
-
|
|
31
|
-
const
|
|
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={
|
|
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=
|
|
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
|
|
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 /> :
|
|
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 }} />
|