@pennyfarthing/cyclist 9.3.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/public/css/react.css +1 -1
- package/dist/public/js/react/react.js +31 -31
- 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/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 +177 -92
- package/src/public/components/PersonaHeader.tsx +45 -15
- package/src/public/components/SubagentSpan.tsx +15 -8
- 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/WorkflowPanel.tsx +85 -12
- package/src/public/components/panels/index.ts +1 -0
- package/src/public/css/theme-system.css +46 -38
- 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 +236 -58
- package/src/public/types/message.ts +4 -0
- package/src/public/utils/slash-commands.ts +1 -1
- package/src/public/utils/toolStackGrouper.ts +5 -6
|
@@ -58,41 +58,99 @@ interface SystemFont {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
// =============================================================================
|
|
61
|
-
// Monospace Detection
|
|
61
|
+
// Monospace Detection via OpenType `post` table
|
|
62
62
|
// =============================================================================
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Parse SFNT table directory to find a table's offset and length.
|
|
66
|
+
* SFNT header: version(4) + numTables(2) + searchRange(2) + entrySelector(2) + rangeShift(2) = 12
|
|
67
|
+
* Each table record: tag(4) + checksum(4) + offset(4) + length(4) = 16
|
|
68
|
+
*/
|
|
69
|
+
function findSfntTable(view: DataView, tag: string): { offset: number; length: number } | null {
|
|
70
|
+
const numTables = view.getUint16(4);
|
|
71
|
+
for (let i = 0; i < numTables; i++) {
|
|
72
|
+
const recordOffset = 12 + i * 16;
|
|
73
|
+
const tableTag = String.fromCharCode(
|
|
74
|
+
view.getUint8(recordOffset),
|
|
75
|
+
view.getUint8(recordOffset + 1),
|
|
76
|
+
view.getUint8(recordOffset + 2),
|
|
77
|
+
view.getUint8(recordOffset + 3),
|
|
78
|
+
);
|
|
79
|
+
if (tableTag === tag) {
|
|
80
|
+
return {
|
|
81
|
+
offset: view.getUint32(recordOffset + 8),
|
|
82
|
+
length: view.getUint32(recordOffset + 12),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
69
85
|
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
70
88
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Read `isFixedPitch` from the `post` table.
|
|
91
|
+
* post layout: version(4) + italicAngle(4) + underlinePosition(2) + underlineThickness(2) = offset 12
|
|
92
|
+
* isFixedPitch is uint32 at offset 12: 0 = proportional, non-zero = monospace.
|
|
93
|
+
*/
|
|
94
|
+
async function detectMonospaceFromBlob(fontData: { blob: () => Promise<Blob> }): Promise<boolean> {
|
|
95
|
+
try {
|
|
96
|
+
const blob = await fontData.blob();
|
|
97
|
+
const buffer = await blob.arrayBuffer();
|
|
98
|
+
const view = new DataView(buffer);
|
|
99
|
+
|
|
100
|
+
const post = findSfntTable(view, 'post');
|
|
101
|
+
if (post) {
|
|
102
|
+
const isFixedPitch = view.getUint32(post.offset + 12);
|
|
103
|
+
return isFixedPitch !== 0;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
} catch {
|
|
75
107
|
return false;
|
|
76
108
|
}
|
|
77
|
-
|
|
78
|
-
ctx.font = `16px "${fontFamily}", monospace`;
|
|
79
|
-
const wideChar = ctx.measureText('W').width;
|
|
80
|
-
const narrowChar = ctx.measureText('i').width;
|
|
81
|
-
const isMono = Math.abs(wideChar - narrowChar) < 1;
|
|
82
|
-
|
|
83
|
-
monoCache.set(fontFamily, isMono);
|
|
84
|
-
return isMono;
|
|
85
109
|
}
|
|
86
110
|
|
|
87
111
|
// =============================================================================
|
|
88
112
|
// System Font Discovery
|
|
89
113
|
// =============================================================================
|
|
90
114
|
|
|
91
|
-
|
|
115
|
+
interface FontDataEntry {
|
|
116
|
+
family: string;
|
|
117
|
+
fullName: string;
|
|
118
|
+
postscriptName: string;
|
|
119
|
+
style: string;
|
|
120
|
+
blob: () => Promise<Blob>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const FONT_CACHE_KEY = 'cyclist-system-fonts';
|
|
124
|
+
|
|
125
|
+
interface FontCacheData {
|
|
126
|
+
fonts: SystemFont[];
|
|
127
|
+
count: number; // number of font families — if it changes, fonts were installed/removed
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function loadCachedFonts(): SystemFont[] | null {
|
|
131
|
+
try {
|
|
132
|
+
const raw = localStorage.getItem(FONT_CACHE_KEY);
|
|
133
|
+
if (!raw) return null;
|
|
134
|
+
const data: FontCacheData = JSON.parse(raw);
|
|
135
|
+
if (data.fonts?.length > 0) return data.fonts;
|
|
136
|
+
return null;
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function saveCachedFonts(fonts: SystemFont[], count: number): void {
|
|
143
|
+
try {
|
|
144
|
+
const data: FontCacheData = { fonts, count };
|
|
145
|
+
localStorage.setItem(FONT_CACHE_KEY, JSON.stringify(data));
|
|
146
|
+
} catch {
|
|
147
|
+
// localStorage full or unavailable — not critical
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
92
151
|
let systemFontsPromise: Promise<SystemFont[]> | null = null;
|
|
93
152
|
|
|
94
153
|
async function getSystemFonts(): Promise<SystemFont[]> {
|
|
95
|
-
if (systemFontsCache) return systemFontsCache;
|
|
96
154
|
if (systemFontsPromise) return systemFontsPromise;
|
|
97
155
|
|
|
98
156
|
systemFontsPromise = (async () => {
|
|
@@ -101,24 +159,48 @@ async function getSystemFonts(): Promise<SystemFont[]> {
|
|
|
101
159
|
}
|
|
102
160
|
|
|
103
161
|
try {
|
|
104
|
-
const fonts = await (window as unknown as { queryLocalFonts: () => Promise<
|
|
162
|
+
const fonts: FontDataEntry[] = await (window as unknown as { queryLocalFonts: () => Promise<FontDataEntry[]> }).queryLocalFonts();
|
|
105
163
|
|
|
106
|
-
// Deduplicate by family name
|
|
107
|
-
const
|
|
164
|
+
// Deduplicate by family name, keep one FontData per family for mono detection
|
|
165
|
+
const familyMap = new Map<string, FontDataEntry>();
|
|
108
166
|
for (const font of fonts) {
|
|
109
|
-
|
|
167
|
+
if (!familyMap.has(font.family)) {
|
|
168
|
+
familyMap.set(font.family, font);
|
|
169
|
+
}
|
|
110
170
|
}
|
|
111
171
|
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
172
|
+
const familyCount = familyMap.size;
|
|
173
|
+
|
|
174
|
+
// Check localStorage cache — reuse if font count hasn't changed
|
|
175
|
+
const cached = loadCachedFonts();
|
|
176
|
+
if (cached && cached.length > 0) {
|
|
177
|
+
// Load raw cached data to check count
|
|
178
|
+
try {
|
|
179
|
+
const raw = localStorage.getItem(FONT_CACHE_KEY);
|
|
180
|
+
if (raw) {
|
|
181
|
+
const data: FontCacheData = JSON.parse(raw);
|
|
182
|
+
if (data.count === familyCount) {
|
|
183
|
+
return cached;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
// Fall through to re-detect
|
|
188
|
+
}
|
|
118
189
|
}
|
|
119
190
|
|
|
191
|
+
// Detect monospace via post table in parallel
|
|
192
|
+
const entries = Array.from(familyMap.entries());
|
|
193
|
+
const monoResults = await Promise.all(
|
|
194
|
+
entries.map(([, fontData]) => detectMonospaceFromBlob(fontData))
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const result: SystemFont[] = entries.map(([family], i) => ({
|
|
198
|
+
family,
|
|
199
|
+
isMonospace: monoResults[i],
|
|
200
|
+
}));
|
|
201
|
+
|
|
120
202
|
result.sort((a, b) => a.family.localeCompare(b.family));
|
|
121
|
-
|
|
203
|
+
saveCachedFonts(result, familyCount);
|
|
122
204
|
return result;
|
|
123
205
|
} catch {
|
|
124
206
|
// Permission denied or API error
|
|
@@ -161,10 +243,13 @@ export function FontPicker({
|
|
|
161
243
|
}
|
|
162
244
|
}, [fontsLoaded]);
|
|
163
245
|
|
|
164
|
-
// Filter system fonts
|
|
246
|
+
// Filter system fonts: English-only (Latin names), monospace-only for code
|
|
165
247
|
const filteredSystemFonts = useMemo(() => {
|
|
166
248
|
let fonts = systemFonts;
|
|
167
249
|
|
|
250
|
+
// Filter to fonts with Latin-script names (excludes CJK, Arabic, Devanagari, etc.)
|
|
251
|
+
fonts = fonts.filter(f => /^[\x20-\x7E\u00C0-\u024F]+$/.test(f.family));
|
|
252
|
+
|
|
168
253
|
// For code fonts, only show monospace
|
|
169
254
|
if (type === 'code') {
|
|
170
255
|
fonts = fonts.filter(f => f.isMonospace);
|
|
@@ -237,7 +322,7 @@ export function FontPicker({
|
|
|
237
322
|
>
|
|
238
323
|
<SelectValue placeholder="Select font..." />
|
|
239
324
|
</SelectTrigger>
|
|
240
|
-
<SelectContent>
|
|
325
|
+
<SelectContent className="max-h-[300px]">
|
|
241
326
|
{/* Presets section */}
|
|
242
327
|
{displayPresets.length > 0 && (
|
|
243
328
|
<SelectGroup>
|
|
@@ -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 }} />
|