a1013342a3
* feat: implement real-time git status integration - Add workspace tracker to monitor git changes every 2 seconds - Implement WebSocket streaming for git status updates - Add database persistence for git status in agent session metadata - Compute diffs backend-side with additions/deletions per file - Integrate git status into frontend UI (top bar, files panel, changes panel) - Display real-time ahead/behind commits and line changes - Show file diffs with syntax highlighting in task view - Add retry logic for WebSocket connections during reconciliation - Fix context lifecycle issue in workspace tracker * refactor: remove debug and unnecessary logging - Remove debug logs from workspace tracker - Remove info logs from WebSocket handlers - Remove verbose git status update logs - Keep only essential error logs * fix: compare git diff against remote branch instead of HEAD - Changed git diff comparison from HEAD to remote tracking branch - Now shows all changes including committed files that haven't been pushed - Files committed locally but not pushed will appear in the diff - Falls back to HEAD if no remote branch is configured * fix: prevent stale git status when switching tasks - Clear git status when switching to a new task - Only accept git.status WebSocket updates for the current task - Prevents showing git data from previous task when switching * refactor: remove unused diff stream client code - Remove diffConn field from agentctl Client struct - Remove StreamDiff() method (never called) - Remove CloseDiffStream() method - Remove DiffUpdate type from client.go - All diff data is already included in GitStatusUpdate.Files - Keep server-side diff endpoint in agentctl for potential future use * refactor: remove unused diff stream server-side code - Remove /workspace/diff/stream endpoint from agentctl API - Remove handleDiffStreamWS handler - Remove DiffUpdate type from workspace_tracker.go - Remove DiffSubscriber type - Remove updateDiff() and getDiff() methods - Remove SubscribeDiff(), UnsubscribeDiff() methods - Remove notifyDiffSubscribers() method - Remove GetCurrentDiff() method - Remove currentDiff field and diffSubscribers map - All diff data is already included in GitStatusUpdate.Files * refactor: remove unused output stream code - Remove /output and /output/stream endpoints from agentctl API - Remove handleGetOutput and handleOutputStreamWS handlers - Delete output.go file entirely - Remove OutputBuffer and related code (buffer.go deleted) - Remove outputConn field from agentctl Client - Remove StreamOutput() and CloseOutputStream() methods - Remove OutputLine type from client.go - Remove OutputBufferSize config option - Update readStderr to just log instead of buffering Output data flows through ACP stream (StreamUpdates) which includes all session notifications. The separate output stream was never used.
237 lines
8.6 KiB
TypeScript
237 lines
8.6 KiB
TypeScript
'use client';
|
|
|
|
import type { CSSProperties } from 'react';
|
|
import { memo, useMemo, useState } from 'react';
|
|
import {
|
|
IconArrowBackUp,
|
|
IconChevronRight,
|
|
IconExternalLink,
|
|
IconFile,
|
|
IconFolder,
|
|
} from '@tabler/icons-react';
|
|
import { Badge } from '@kandev/ui/badge';
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from '@kandev/ui/collapsible';
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarGroup,
|
|
SidebarGroupContent,
|
|
SidebarMenu,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
SidebarMenuSub,
|
|
SidebarProvider,
|
|
SidebarRail,
|
|
} from '@kandev/ui/sidebar';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kandev/ui/tabs';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
|
import { LineStat } from '@/components/diff-stat';
|
|
import { cn } from '@/lib/utils';
|
|
import { FILE_TREE } from '@/components/task/task-data';
|
|
import { useAppStore } from '@/components/state-provider';
|
|
import type { FileInfo } from '@/lib/state/store';
|
|
|
|
type TaskFilesPanelProps = {
|
|
onSelectDiffPath: (path: string) => void;
|
|
};
|
|
|
|
type TreeItem = string | TreeItem[];
|
|
|
|
const badgeClass = (status: string) =>
|
|
cn(
|
|
'text-[10px] font-semibold',
|
|
(status === 'M' || status === 'modified') && 'bg-yellow-500/15 text-yellow-700',
|
|
(status === 'A' || status === 'added') && 'bg-emerald-500/15 text-emerald-700',
|
|
(status === 'D' || status === 'deleted') && 'bg-rose-500/15 text-rose-700',
|
|
status === 'untracked' && 'bg-blue-500/15 text-blue-700',
|
|
status === 'renamed' && 'bg-purple-500/15 text-purple-700'
|
|
);
|
|
|
|
const statusLabel = (status: FileInfo['status']): string => {
|
|
switch (status) {
|
|
case 'modified': return 'M';
|
|
case 'added': return 'A';
|
|
case 'deleted': return 'D';
|
|
case 'untracked': return 'U';
|
|
case 'renamed': return 'R';
|
|
default: return '?';
|
|
}
|
|
};
|
|
|
|
const splitPath = (path: string) => {
|
|
const lastSlash = path.lastIndexOf('/');
|
|
if (lastSlash === -1) return { folder: '', file: path };
|
|
return {
|
|
folder: path.slice(0, lastSlash),
|
|
file: path.slice(lastSlash + 1),
|
|
};
|
|
};
|
|
|
|
function Tree({ item }: { item: TreeItem }) {
|
|
const [name, ...items] = Array.isArray(item) ? item : [item];
|
|
|
|
if (!items.length) {
|
|
return (
|
|
<SidebarMenuButton className="data-[active=true]:bg-transparent">
|
|
<IconFile className="h-4 w-4" />
|
|
{name}
|
|
</SidebarMenuButton>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SidebarMenuItem>
|
|
<Collapsible
|
|
className="group/collapsible [&[data-state=open]>button>svg:first-child]:rotate-90"
|
|
defaultOpen={name === 'components' || name === 'ui'}
|
|
>
|
|
<CollapsibleTrigger asChild>
|
|
<SidebarMenuButton>
|
|
<IconChevronRight className="h-4 w-4 transition-transform" />
|
|
<IconFolder className="h-4 w-4" />
|
|
{name}
|
|
</SidebarMenuButton>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<SidebarMenuSub>
|
|
{items.map((subItem, index) => (
|
|
<Tree key={index} item={subItem} />
|
|
))}
|
|
</SidebarMenuSub>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</SidebarMenuItem>
|
|
);
|
|
}
|
|
|
|
const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiffPath }: TaskFilesPanelProps) {
|
|
const [topTab, setTopTab] = useState<'diff' | 'files'>('diff');
|
|
const gitStatus = useAppStore((state) => state.gitStatus);
|
|
|
|
// Convert git status files to array for display
|
|
const changedFiles = useMemo(() => {
|
|
if (!gitStatus.files || Object.keys(gitStatus.files).length === 0) {
|
|
return [];
|
|
}
|
|
return Object.values(gitStatus.files).map((file) => ({
|
|
path: file.path,
|
|
status: file.status,
|
|
plus: file.additions,
|
|
minus: file.deletions,
|
|
oldPath: file.old_path,
|
|
}));
|
|
}, [gitStatus.files]);
|
|
|
|
return (
|
|
<div className="h-full min-h-0 bg-card p-4 flex flex-col rounded-lg border border-border/70 border-l-0">
|
|
<Tabs value={topTab} onValueChange={(value) => setTopTab(value as 'diff' | 'files')} className="flex-1 min-h-0">
|
|
<TabsList>
|
|
<TabsTrigger value="diff" className="cursor-pointer">
|
|
Diff files {changedFiles.length > 0 && `(${changedFiles.length})`}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="files" className="cursor-pointer">
|
|
All files
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="diff" className="mt-3 flex-1 min-h-0">
|
|
<div className="flex-1 min-h-0 overflow-y-auto rounded-lg bg-background p-3 h-full">
|
|
{changedFiles.length === 0 ? (
|
|
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
|
No changes detected
|
|
</div>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{changedFiles.map((file) => {
|
|
const { folder, file: name } = splitPath(file.path);
|
|
return (
|
|
<li
|
|
key={file.path}
|
|
className="group flex items-center justify-between gap-3 text-sm rounded-md px-1 py-0.5 -mx-1 hover:bg-muted/60 cursor-pointer"
|
|
onClick={() => onSelectDiffPath(file.path)}
|
|
>
|
|
<button type="button" className="min-w-0 text-left cursor-pointer">
|
|
<p className="truncate text-foreground">
|
|
<span className="text-foreground/60">{folder}/</span>
|
|
<span className="font-medium text-foreground">{name}</span>
|
|
</p>
|
|
{file.oldPath && (
|
|
<p className="truncate text-xs text-muted-foreground">
|
|
Renamed from: {file.oldPath}
|
|
</p>
|
|
)}
|
|
</button>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="text-muted-foreground hover:text-foreground"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
}}
|
|
>
|
|
<IconArrowBackUp className="h-3.5 w-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Discard changes</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="text-muted-foreground hover:text-foreground"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
}}
|
|
>
|
|
<IconExternalLink className="h-3.5 w-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Open in editor</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
<LineStat added={file.plus} removed={file.minus} />
|
|
<Badge className={badgeClass(file.status)}>{statusLabel(file.status)}</Badge>
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
<TabsContent value="files" className="mt-3 flex-1 min-h-0">
|
|
<div className="flex-1 min-h-0 overflow-y-auto rounded-lg bg-background h-full">
|
|
<SidebarProvider
|
|
className="h-full w-full"
|
|
style={{ "--sidebar-width": "100%" } as CSSProperties}
|
|
>
|
|
<Sidebar collapsible="none" className="h-full w-full">
|
|
<SidebarContent>
|
|
<SidebarGroup>
|
|
<SidebarGroupContent>
|
|
<SidebarMenu>
|
|
{FILE_TREE.map((item, index) => (
|
|
<Tree key={index} item={item} />
|
|
))}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
</SidebarContent>
|
|
<SidebarRail />
|
|
</Sidebar>
|
|
</SidebarProvider>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export { TaskFilesPanel };
|