Files
kandev/apps/web/components/task/task-files-panel.tsx
T
João Salavisa a1013342a3 feat: Real-time Git Status Integration (#8)
* 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.
2026-01-12 23:44:29 +00:00

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 };