groove-dev 0.27.87 → 0.27.88

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 (53) hide show
  1. package/CLAUDE.md +3 -2
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/api.js +115 -7
  5. package/node_modules/@groove-dev/daemon/src/conversations.js +29 -3
  6. package/node_modules/@groove-dev/daemon/src/providers/codex.js +28 -10
  7. package/node_modules/@groove-dev/daemon/src/registry.js +30 -0
  8. package/node_modules/@groove-dev/daemon/src/validate.js +23 -0
  9. package/node_modules/@groove-dev/gui/dist/assets/index-BSqk8cbI.css +1 -0
  10. package/node_modules/@groove-dev/gui/dist/assets/index-B_igwWvq.js +8642 -0
  11. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  12. package/node_modules/@groove-dev/gui/package.json +1 -1
  13. package/node_modules/@groove-dev/gui/src/components/agents/agent-file-tree.jsx +254 -0
  14. package/node_modules/@groove-dev/gui/src/components/agents/code-review.jsx +177 -0
  15. package/node_modules/@groove-dev/gui/src/components/agents/diff-viewer.jsx +148 -0
  16. package/node_modules/@groove-dev/gui/src/components/agents/workspace-mode.jsx +377 -0
  17. package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +117 -40
  18. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +10 -13
  19. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +26 -1
  20. package/node_modules/@groove-dev/gui/src/components/chat/conversation-list.jsx +14 -14
  21. package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +5 -0
  22. package/node_modules/@groove-dev/gui/src/stores/groove.js +132 -1
  23. package/node_modules/@groove-dev/gui/src/views/agents.jsx +22 -3
  24. package/package.json +1 -1
  25. package/packages/cli/package.json +1 -1
  26. package/packages/daemon/package.json +1 -1
  27. package/packages/daemon/src/api.js +115 -7
  28. package/packages/daemon/src/conversations.js +29 -3
  29. package/packages/daemon/src/providers/codex.js +28 -10
  30. package/packages/daemon/src/registry.js +30 -0
  31. package/packages/daemon/src/validate.js +23 -0
  32. package/packages/gui/dist/assets/index-BSqk8cbI.css +1 -0
  33. package/packages/gui/dist/assets/index-B_igwWvq.js +8642 -0
  34. package/packages/gui/dist/index.html +2 -2
  35. package/packages/gui/package.json +1 -1
  36. package/packages/gui/src/components/agents/agent-file-tree.jsx +254 -0
  37. package/packages/gui/src/components/agents/code-review.jsx +177 -0
  38. package/packages/gui/src/components/agents/diff-viewer.jsx +148 -0
  39. package/packages/gui/src/components/agents/workspace-mode.jsx +377 -0
  40. package/packages/gui/src/components/chat/chat-input.jsx +117 -40
  41. package/packages/gui/src/components/chat/chat-messages.jsx +10 -13
  42. package/packages/gui/src/components/chat/chat-view.jsx +26 -1
  43. package/packages/gui/src/components/chat/conversation-list.jsx +14 -14
  44. package/packages/gui/src/components/chat/model-picker.jsx +5 -0
  45. package/packages/gui/src/stores/groove.js +132 -1
  46. package/packages/gui/src/views/agents.jsx +22 -3
  47. package/test/doomsday-clock/index.html +55 -0
  48. package/test/doomsday-clock/script.js +66 -0
  49. package/test/doomsday-clock/style.css +315 -0
  50. package/node_modules/@groove-dev/gui/dist/assets/index-BCQY8ojz.css +0 -1
  51. package/node_modules/@groove-dev/gui/dist/assets/index-C5e7KVGN.js +0 -8637
  52. package/packages/gui/dist/assets/index-BCQY8ojz.css +0 -1
  53. package/packages/gui/dist/assets/index-C5e7KVGN.js +0 -8637
@@ -6,12 +6,12 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <link rel="icon" type="image/png" href="/favicon.png" />
8
8
  <title>Groove GUI</title>
9
- <script type="module" crossorigin src="/assets/index-C5e7KVGN.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-B_igwWvq.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="/assets/vendor-C0HXlhrU.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/reactflow-BQPfi37R.js">
12
12
  <link rel="modulepreload" crossorigin href="/assets/codemirror-BBL3i_JW.js">
13
13
  <link rel="modulepreload" crossorigin href="/assets/xterm--7_ns2zW.js">
14
- <link rel="stylesheet" crossorigin href="/assets/index-BCQY8ojz.css">
14
+ <link rel="stylesheet" crossorigin href="/assets/index-BSqk8cbI.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.87",
3
+ "version": "0.27.88",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,254 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { useState, useEffect, useRef, useCallback } from 'react';
3
+ import { useGrooveStore } from '../../stores/groove';
4
+ import { cn } from '../../lib/cn';
5
+ import { api } from '../../lib/api';
6
+ import { ChevronRight, ChevronDown, File, Folder, FolderOpen, Clock } from 'lucide-react';
7
+ import { ScrollArea } from '../ui/scroll-area';
8
+
9
+ const FILE_COLORS = {
10
+ js: 'text-warning', jsx: 'text-warning', ts: 'text-info', tsx: 'text-info',
11
+ css: 'text-info', html: 'text-orange', json: 'text-warning',
12
+ md: 'text-text-2', py: 'text-success', rs: 'text-orange',
13
+ go: 'text-accent', sh: 'text-success', yaml: 'text-danger', yml: 'text-danger',
14
+ sql: 'text-purple', xml: 'text-orange', svg: 'text-warning',
15
+ };
16
+
17
+ function getFileColor(name) {
18
+ const ext = name.split('.').pop()?.toLowerCase();
19
+ return FILE_COLORS[ext] || 'text-text-3';
20
+ }
21
+
22
+ function matchesScope(filePath, scopePatterns) {
23
+ if (!scopePatterns || scopePatterns.length === 0) return true;
24
+ for (const pattern of scopePatterns) {
25
+ const escaped = pattern
26
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
27
+ .replace(/\*\*/g, '<<GLOBSTAR>>')
28
+ .replace(/\*/g, '[^/]*')
29
+ .replace(/<<GLOBSTAR>>/g, '.*');
30
+ if (new RegExp(`^${escaped}$`).test(filePath) || new RegExp(`^${escaped}`).test(filePath)) {
31
+ return true;
32
+ }
33
+ }
34
+ return false;
35
+ }
36
+
37
+ function TreeEntry({ entry, depth, onOpen, expandedDirs, onToggleDir }) {
38
+ const isDir = entry.type === 'directory';
39
+ const isExpanded = expandedDirs.has(entry.path);
40
+ const fileColor = isDir ? 'text-accent' : getFileColor(entry.name);
41
+
42
+ return (
43
+ <>
44
+ <button
45
+ onClick={() => isDir ? onToggleDir(entry.path) : onOpen(entry.path)}
46
+ className={cn(
47
+ 'w-full flex items-center gap-1.5 py-1 text-xs font-sans cursor-pointer',
48
+ 'hover:bg-surface-4/50 transition-colors text-left',
49
+ )}
50
+ style={{ paddingLeft: depth * 14 + 8 }}
51
+ >
52
+ {isDir ? (
53
+ <>
54
+ {isExpanded ? <ChevronDown size={12} className="text-text-4 flex-shrink-0" /> : <ChevronRight size={12} className="text-text-4 flex-shrink-0" />}
55
+ {isExpanded ? <FolderOpen size={13} className={cn(fileColor, 'flex-shrink-0')} /> : <Folder size={13} className={cn(fileColor, 'flex-shrink-0')} />}
56
+ </>
57
+ ) : (
58
+ <>
59
+ <span className="w-3 flex-shrink-0" />
60
+ <File size={13} className={cn(fileColor, 'flex-shrink-0')} />
61
+ </>
62
+ )}
63
+ <span className="truncate text-text-1">{entry.name}</span>
64
+ </button>
65
+ {isDir && isExpanded && entry.children?.map((child) => (
66
+ <TreeEntry
67
+ key={child.path}
68
+ entry={child}
69
+ depth={depth + 1}
70
+ onOpen={onOpen}
71
+ expandedDirs={expandedDirs}
72
+ onToggleDir={onToggleDir}
73
+ />
74
+ ))}
75
+ </>
76
+ );
77
+ }
78
+
79
+ export function AgentFileTree({ agentId }) {
80
+ const agents = useGrooveStore((s) => s.agents);
81
+ const activityLog = useGrooveStore((s) => s.activityLog);
82
+ const openFile = useGrooveStore((s) => s.openFile);
83
+ const editorActiveFile = useGrooveStore((s) => s.editorActiveFile);
84
+
85
+ const agent = agents.find((a) => a.id === agentId);
86
+ const scope = agent?.scope || [];
87
+ const log = activityLog[agentId] || [];
88
+
89
+ const [treeData, setTreeData] = useState([]);
90
+ const [expandedDirs, setExpandedDirs] = useState(new Set());
91
+ const [loading, setLoading] = useState(true);
92
+ const fetchedRef = useRef(new Set());
93
+
94
+ const recentFiles = (() => {
95
+ const seen = new Set();
96
+ const files = [];
97
+ for (let i = log.length - 1; i >= 0; i--) {
98
+ const t = (log[i].text || '').toLowerCase();
99
+ if (!(t.includes('writ') || t.includes('edit') || t.includes('creat') || t.includes('read'))) continue;
100
+ const match = log[i].text.match(/(?:Write|Edit|Create|Read|wrote|editing|writing|reading)\S*\s+(\S+)/i);
101
+ if (!match) continue;
102
+ const path = match[1];
103
+ if (seen.has(path) || path.startsWith('.') || path.includes('node_modules')) continue;
104
+ seen.add(path);
105
+ files.push(path);
106
+ if (files.length >= 10) break;
107
+ }
108
+ return files;
109
+ })();
110
+
111
+ const fetchDir = useCallback(async (dirPath) => {
112
+ if (fetchedRef.current.has(dirPath)) return;
113
+ fetchedRef.current.add(dirPath);
114
+ try {
115
+ const data = await api.get(`/files/tree?path=${encodeURIComponent(dirPath)}`);
116
+ return data.entries || [];
117
+ } catch {
118
+ return [];
119
+ }
120
+ }, []);
121
+
122
+ useEffect(() => {
123
+ let cancelled = false;
124
+ async function loadTree() {
125
+ setLoading(true);
126
+ fetchedRef.current = new Set();
127
+
128
+ if (scope.length === 0) {
129
+ const entries = await fetchDir('');
130
+ if (!cancelled) setTreeData(entries);
131
+ setLoading(false);
132
+ return;
133
+ }
134
+
135
+ const dirs = new Set();
136
+ for (const pattern of scope) {
137
+ const parts = pattern.split('/');
138
+ let dir = '';
139
+ for (let i = 0; i < parts.length; i++) {
140
+ if (parts[i].includes('*')) break;
141
+ dir = dir ? `${dir}/${parts[i]}` : parts[i];
142
+ }
143
+ if (dir) dirs.add(dir);
144
+ }
145
+
146
+ if (dirs.size === 0) {
147
+ const entries = await fetchDir('');
148
+ if (!cancelled) setTreeData(entries);
149
+ setLoading(false);
150
+ return;
151
+ }
152
+
153
+ const results = [];
154
+ for (const dir of dirs) {
155
+ const entries = await fetchDir(dir);
156
+ if (entries.length > 0) {
157
+ results.push({ name: dir.split('/').pop(), path: dir, type: 'directory', children: entries });
158
+ }
159
+ }
160
+ if (!cancelled) setTreeData(results);
161
+ setLoading(false);
162
+ }
163
+ loadTree();
164
+ return () => { cancelled = true; };
165
+ }, [agentId, scope.join(','), fetchDir]);
166
+
167
+ async function handleToggleDir(path) {
168
+ const next = new Set(expandedDirs);
169
+ if (next.has(path)) {
170
+ next.delete(path);
171
+ } else {
172
+ next.add(path);
173
+ const entries = await fetchDir(path);
174
+ setTreeData((prev) => updateTreeChildren(prev, path, entries));
175
+ }
176
+ setExpandedDirs(next);
177
+ }
178
+
179
+ function handleOpen(path) {
180
+ openFile(path);
181
+ }
182
+
183
+ return (
184
+ <ScrollArea className="h-full">
185
+ <div className="py-2">
186
+ {recentFiles.length > 0 && (
187
+ <div className="mb-3">
188
+ <div className="flex items-center gap-1.5 px-3 py-1.5 text-2xs font-semibold text-text-3 uppercase tracking-wider">
189
+ <Clock size={10} />
190
+ Recently Touched
191
+ </div>
192
+ {recentFiles.map((path) => {
193
+ const name = path.split('/').pop();
194
+ return (
195
+ <button
196
+ key={path}
197
+ onClick={() => openFile(path)}
198
+ className={cn(
199
+ 'w-full flex items-center gap-1.5 px-3 py-1 text-xs font-sans cursor-pointer',
200
+ 'hover:bg-surface-4/50 transition-colors text-left',
201
+ editorActiveFile === path && 'bg-accent/8 text-accent',
202
+ )}
203
+ >
204
+ <File size={12} className={cn(getFileColor(name), 'flex-shrink-0')} />
205
+ <span className="truncate text-text-1">{name}</span>
206
+ </button>
207
+ );
208
+ })}
209
+ <div className="h-px bg-border-subtle mx-3 mt-2" />
210
+ </div>
211
+ )}
212
+
213
+ {loading ? (
214
+ <div className="flex items-center justify-center py-8 text-text-4 text-xs font-sans">
215
+ Loading...
216
+ </div>
217
+ ) : treeData.length === 0 ? (
218
+ <div className="flex items-center justify-center py-8 text-text-4 text-xs font-sans">
219
+ No files in scope
220
+ </div>
221
+ ) : (
222
+ <div className="px-1">
223
+ <div className="flex items-center gap-1.5 px-2 py-1.5 text-2xs font-semibold text-text-3 uppercase tracking-wider">
224
+ <Folder size={10} />
225
+ Scope
226
+ </div>
227
+ {treeData.map((entry) => (
228
+ <TreeEntry
229
+ key={entry.path}
230
+ entry={entry}
231
+ depth={0}
232
+ onOpen={handleOpen}
233
+ expandedDirs={expandedDirs}
234
+ onToggleDir={handleToggleDir}
235
+ />
236
+ ))}
237
+ </div>
238
+ )}
239
+ </div>
240
+ </ScrollArea>
241
+ );
242
+ }
243
+
244
+ function updateTreeChildren(tree, targetPath, children) {
245
+ return tree.map((entry) => {
246
+ if (entry.path === targetPath) {
247
+ return { ...entry, children };
248
+ }
249
+ if (entry.children) {
250
+ return { ...entry, children: updateTreeChildren(entry.children, targetPath, children) };
251
+ }
252
+ return entry;
253
+ });
254
+ }
@@ -0,0 +1,177 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { useState } from 'react';
3
+ import { useGrooveStore } from '../../stores/groove';
4
+ import { cn } from '../../lib/cn';
5
+ import { ScrollArea } from '../ui/scroll-area';
6
+ import { Button } from '../ui/button';
7
+ import { DiffViewer } from './diff-viewer';
8
+ import { Check, X, MessageSquare, ChevronLeft, CheckCircle2, XCircle, Send } from 'lucide-react';
9
+
10
+ export function CodeReview({ agentId }) {
11
+ const reviewFiles = useGrooveStore((s) => s.workspaceReviewFiles);
12
+ const approveFile = useGrooveStore((s) => s.approveFile);
13
+ const rejectFile = useGrooveStore((s) => s.rejectFile);
14
+ const commentFile = useGrooveStore((s) => s.commentFile);
15
+ const instructAgent = useGrooveStore((s) => s.instructAgent);
16
+ const toggleReviewMode = useGrooveStore((s) => s.toggleReviewMode);
17
+ const openFile = useGrooveStore((s) => s.openFile);
18
+
19
+ const [selectedFile, setSelectedFile] = useState(null);
20
+ const [commentingPath, setCommentingPath] = useState(null);
21
+ const [commentText, setCommentText] = useState('');
22
+
23
+ const approved = reviewFiles.filter((f) => f.status === 'approved').length;
24
+ const rejected = reviewFiles.filter((f) => f.status === 'rejected').length;
25
+
26
+ function handleComment(path) {
27
+ if (!commentText.trim()) return;
28
+ commentFile(path, commentText.trim());
29
+ setCommentText('');
30
+ setCommentingPath(null);
31
+ }
32
+
33
+ function handleApproveAll() {
34
+ for (const f of reviewFiles) {
35
+ approveFile(f.path);
36
+ }
37
+ }
38
+
39
+ async function handleRequestChanges() {
40
+ const comments = reviewFiles
41
+ .filter((f) => f.comment || f.status === 'rejected')
42
+ .map((f) => {
43
+ const status = f.status === 'rejected' ? '[REJECTED]' : '[COMMENT]';
44
+ return `${status} ${f.path}: ${f.comment || 'Changes needed'}`;
45
+ });
46
+ if (comments.length > 0) {
47
+ await instructAgent(agentId, `Code review feedback:\n${comments.join('\n')}`);
48
+ }
49
+ toggleReviewMode();
50
+ }
51
+
52
+ if (selectedFile) {
53
+ return (
54
+ <div className="flex flex-col h-full">
55
+ <div className="flex items-center gap-2 px-4 py-2 bg-surface-1 border-b border-border flex-shrink-0">
56
+ <button
57
+ onClick={() => setSelectedFile(null)}
58
+ className="p-1 rounded hover:bg-surface-4 text-text-3 hover:text-text-1 cursor-pointer"
59
+ >
60
+ <ChevronLeft size={14} />
61
+ </button>
62
+ <span className="text-xs font-mono text-text-1 truncate">{selectedFile}</span>
63
+ </div>
64
+ <div className="flex-1 min-h-0">
65
+ <DiffViewer filePath={selectedFile} />
66
+ </div>
67
+ </div>
68
+ );
69
+ }
70
+
71
+ return (
72
+ <div className="flex flex-col h-full">
73
+ <div className="flex items-center gap-3 px-4 py-3 bg-surface-1 border-b border-border flex-shrink-0">
74
+ <span className="text-sm font-semibold text-text-0 font-sans flex-1">Review Changes</span>
75
+ <span className="text-xs text-text-3 font-sans">
76
+ {reviewFiles.length} file{reviewFiles.length !== 1 ? 's' : ''} changed
77
+ </span>
78
+ {approved > 0 && <span className="text-xs text-success font-sans">{approved} approved</span>}
79
+ {rejected > 0 && <span className="text-xs text-danger font-sans">{rejected} rejected</span>}
80
+ </div>
81
+
82
+ <ScrollArea className="flex-1">
83
+ <div className="p-2 space-y-1">
84
+ {reviewFiles.length === 0 && (
85
+ <div className="flex items-center justify-center py-12 text-text-4 text-xs font-sans">
86
+ No modified files found
87
+ </div>
88
+ )}
89
+ {reviewFiles.map((file) => (
90
+ <div key={file.path} className="rounded-md border border-border-subtle bg-surface-2">
91
+ <div className="flex items-center gap-2 px-3 py-2">
92
+ <button
93
+ onClick={() => { openFile(file.path); setSelectedFile(file.path); }}
94
+ className="flex-1 min-w-0 text-xs font-mono text-text-1 hover:text-accent truncate text-left cursor-pointer"
95
+ >
96
+ {file.path}
97
+ </button>
98
+ <div className="flex items-center gap-1 flex-shrink-0">
99
+ <button
100
+ onClick={() => approveFile(file.path)}
101
+ className={cn(
102
+ 'p-1 rounded cursor-pointer transition-colors',
103
+ file.status === 'approved'
104
+ ? 'bg-success/15 text-success'
105
+ : 'text-text-4 hover:text-success hover:bg-success/10',
106
+ )}
107
+ title="Approve"
108
+ >
109
+ <Check size={14} />
110
+ </button>
111
+ <button
112
+ onClick={() => rejectFile(file.path)}
113
+ className={cn(
114
+ 'p-1 rounded cursor-pointer transition-colors',
115
+ file.status === 'rejected'
116
+ ? 'bg-danger/15 text-danger'
117
+ : 'text-text-4 hover:text-danger hover:bg-danger/10',
118
+ )}
119
+ title="Reject"
120
+ >
121
+ <X size={14} />
122
+ </button>
123
+ <button
124
+ onClick={() => setCommentingPath(commentingPath === file.path ? null : file.path)}
125
+ className={cn(
126
+ 'p-1 rounded cursor-pointer transition-colors',
127
+ file.comment
128
+ ? 'bg-accent/15 text-accent'
129
+ : 'text-text-4 hover:text-accent hover:bg-accent/10',
130
+ )}
131
+ title="Comment"
132
+ >
133
+ <MessageSquare size={14} />
134
+ </button>
135
+ </div>
136
+ </div>
137
+ {file.comment && commentingPath !== file.path && (
138
+ <div className="px-3 pb-2 text-2xs text-text-2 font-sans italic">
139
+ {file.comment}
140
+ </div>
141
+ )}
142
+ {commentingPath === file.path && (
143
+ <div className="flex items-center gap-1.5 px-3 pb-2">
144
+ <input
145
+ value={commentText}
146
+ onChange={(e) => setCommentText(e.target.value)}
147
+ onKeyDown={(e) => { if (e.key === 'Enter') handleComment(file.path); if (e.key === 'Escape') setCommentingPath(null); }}
148
+ placeholder="Add review comment..."
149
+ className="flex-1 h-7 px-2 text-xs bg-surface-0 border border-border-subtle rounded text-text-0 font-sans focus:outline-none focus:border-accent"
150
+ autoFocus
151
+ />
152
+ <button
153
+ onClick={() => handleComment(file.path)}
154
+ className="p-1 text-accent hover:text-accent/80 cursor-pointer"
155
+ >
156
+ <Send size={12} />
157
+ </button>
158
+ </div>
159
+ )}
160
+ </div>
161
+ ))}
162
+ </div>
163
+ </ScrollArea>
164
+
165
+ <div className="flex items-center gap-2 px-4 py-3 border-t border-border bg-surface-1 flex-shrink-0">
166
+ <Button variant="ghost" size="sm" onClick={handleApproveAll} className="gap-1.5">
167
+ <CheckCircle2 size={13} />
168
+ Approve All
169
+ </Button>
170
+ <Button variant="ghost" size="sm" onClick={handleRequestChanges} className="gap-1.5 text-warning">
171
+ <XCircle size={13} />
172
+ Request Changes
173
+ </Button>
174
+ </div>
175
+ </div>
176
+ );
177
+ }
@@ -0,0 +1,148 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { useMemo } from 'react';
3
+ import { useGrooveStore } from '../../stores/groove';
4
+ import { cn } from '../../lib/cn';
5
+ import { ScrollArea } from '../ui/scroll-area';
6
+
7
+ function computeDiff(original, modified) {
8
+ const origLines = (original || '').split('\n');
9
+ const modLines = (modified || '').split('\n');
10
+ const result = [];
11
+
12
+ const maxLen = Math.max(origLines.length, modLines.length);
13
+ let oi = 0, mi = 0;
14
+
15
+ while (oi < origLines.length || mi < modLines.length) {
16
+ if (oi >= origLines.length) {
17
+ result.push({ type: 'add', lineNum: mi + 1, text: modLines[mi] });
18
+ mi++;
19
+ } else if (mi >= modLines.length) {
20
+ result.push({ type: 'del', lineNum: oi + 1, text: origLines[oi] });
21
+ oi++;
22
+ } else if (origLines[oi] === modLines[mi]) {
23
+ result.push({ type: 'same', lineNum: mi + 1, origLineNum: oi + 1, text: modLines[mi] });
24
+ oi++;
25
+ mi++;
26
+ } else {
27
+ let foundOrig = -1;
28
+ let foundMod = -1;
29
+ const lookAhead = Math.min(10, maxLen);
30
+
31
+ for (let k = 1; k <= lookAhead; k++) {
32
+ if (mi + k < modLines.length && origLines[oi] === modLines[mi + k]) {
33
+ foundMod = mi + k;
34
+ break;
35
+ }
36
+ }
37
+ for (let k = 1; k <= lookAhead; k++) {
38
+ if (oi + k < origLines.length && origLines[oi + k] === modLines[mi]) {
39
+ foundOrig = oi + k;
40
+ break;
41
+ }
42
+ }
43
+
44
+ if (foundMod >= 0 && (foundOrig < 0 || foundMod - mi <= foundOrig - oi)) {
45
+ while (mi < foundMod) {
46
+ result.push({ type: 'add', lineNum: mi + 1, text: modLines[mi] });
47
+ mi++;
48
+ }
49
+ } else if (foundOrig >= 0) {
50
+ while (oi < foundOrig) {
51
+ result.push({ type: 'del', lineNum: oi + 1, text: origLines[oi] });
52
+ oi++;
53
+ }
54
+ } else {
55
+ result.push({ type: 'del', lineNum: oi + 1, text: origLines[oi] });
56
+ result.push({ type: 'add', lineNum: mi + 1, text: modLines[mi] });
57
+ oi++;
58
+ mi++;
59
+ }
60
+ }
61
+ }
62
+
63
+ return result;
64
+ }
65
+
66
+ export function DiffViewer({ filePath }) {
67
+ const file = useGrooveStore((s) => s.editorFiles[filePath]);
68
+ const snapshot = useGrooveStore((s) => s.workspaceSnapshots[filePath]);
69
+
70
+ const original = snapshot || file?.originalContent || '';
71
+ const modified = file?.content || '';
72
+
73
+ const diffLines = useMemo(() => computeDiff(original, modified), [original, modified]);
74
+
75
+ const stats = useMemo(() => {
76
+ let adds = 0, dels = 0;
77
+ for (const line of diffLines) {
78
+ if (line.type === 'add') adds++;
79
+ if (line.type === 'del') dels++;
80
+ }
81
+ return { adds, dels };
82
+ }, [diffLines]);
83
+
84
+ if (!file) {
85
+ return (
86
+ <div className="flex items-center justify-center h-full text-text-4 text-xs font-sans">
87
+ No file loaded
88
+ </div>
89
+ );
90
+ }
91
+
92
+ if (original === modified) {
93
+ return (
94
+ <div className="flex items-center justify-center h-full text-text-4 text-xs font-sans">
95
+ No changes detected
96
+ </div>
97
+ );
98
+ }
99
+
100
+ return (
101
+ <div className="flex flex-col h-full">
102
+ <div className="flex items-center gap-3 px-4 py-2 bg-surface-1 border-b border-border-subtle text-xs font-sans flex-shrink-0">
103
+ <span className="text-text-2">{filePath.split('/').pop()}</span>
104
+ <span className="text-success">+{stats.adds}</span>
105
+ <span className="text-danger">-{stats.dels}</span>
106
+ </div>
107
+ <ScrollArea className="flex-1">
108
+ <div className="font-mono text-xs leading-5">
109
+ {diffLines.map((line, i) => (
110
+ <div
111
+ key={i}
112
+ className={cn(
113
+ 'flex',
114
+ line.type === 'add' && 'bg-success/8',
115
+ line.type === 'del' && 'bg-danger/8',
116
+ )}
117
+ >
118
+ <span className={cn(
119
+ 'w-12 flex-shrink-0 text-right pr-3 select-none',
120
+ line.type === 'add' ? 'text-success/60' : line.type === 'del' ? 'text-danger/60' : 'text-text-4',
121
+ )}>
122
+ {line.type === 'add' ? '' : (line.origLineNum || line.lineNum)}
123
+ </span>
124
+ <span className={cn(
125
+ 'w-12 flex-shrink-0 text-right pr-3 select-none',
126
+ line.type === 'add' ? 'text-success/60' : line.type === 'del' ? 'text-danger/60' : 'text-text-4',
127
+ )}>
128
+ {line.type === 'del' ? '' : line.lineNum}
129
+ </span>
130
+ <span className={cn(
131
+ 'w-5 flex-shrink-0 text-center select-none font-bold',
132
+ line.type === 'add' ? 'text-success' : line.type === 'del' ? 'text-danger' : 'text-text-4',
133
+ )}>
134
+ {line.type === 'add' ? '+' : line.type === 'del' ? '-' : ' '}
135
+ </span>
136
+ <span className={cn(
137
+ 'flex-1 whitespace-pre px-2',
138
+ line.type === 'add' ? 'text-success/90' : line.type === 'del' ? 'text-danger/90' : 'text-text-1',
139
+ )}>
140
+ {line.text}
141
+ </span>
142
+ </div>
143
+ ))}
144
+ </div>
145
+ </ScrollArea>
146
+ </div>
147
+ );
148
+ }