Files
kandev/apps/web/components/task/task-center-panel.tsx
T
Carlos Florêncio b22e66750c feat: improved file tree (#164)
feat: improved editor
2026-02-03 04:14:07 +00:00

658 lines
24 KiB
TypeScript

'use client';
import { memo, useCallback, useState, useEffect, useMemo, useRef } from 'react';
import { IconSparkles, IconCheck, IconChevronDown, IconX } from '@tabler/icons-react';
import { TabsContent } from '@kandev/ui/tabs';
import { Button } from '@kandev/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@kandev/ui/dropdown-menu';
import { SessionPanel } from '@kandev/ui/pannel-session';
import { TaskChatPanel } from './task-chat-panel';
import { TaskChangesPanel } from './task-changes-panel';
import { TaskPlanPanel } from './task-plan-panel';
import { FileEditorContent } from './file-editor-content';
import { PassthroughTerminal } from './passthrough-terminal';
import type { OpenFileTab, FileContentResponse } from '@/lib/types/backend';
import { useAppStore } from '@/components/state-provider';
import { SessionTabs, type SessionTab } from '@/components/session-tabs';
import { approveSessionAction } from '@/app/actions/workspaces';
import { getWebSocketClient } from '@/lib/ws/connection';
import { requestFileContent, updateFileContent, deleteFile } from '@/lib/ws/workspace-files';
import { getPlanLastSeen, setPlanLastSeen, getOpenFileTabs, setOpenFileTabs as saveOpenFileTabs, getActiveTabForSession, setActiveTabForSession } from '@/lib/local-storage';
import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status';
import { useSessionCommits } from '@/hooks/domains/session/use-session-commits';
import { generateUnifiedDiff, calculateHash } from '@/lib/utils/file-diff';
import { useToast } from '@/components/toast-provider';
import type { SelectedDiff } from './task-layout';
type TaskCenterPanelProps = {
selectedDiff: SelectedDiff | null;
openFileRequest: OpenFileTab | null;
onDiffHandled: () => void;
onFileOpenHandled: () => void;
onActiveFileChange?: (filePath: string | null) => void;
sessionId?: string | null;
};
export const TaskCenterPanel = memo(function TaskCenterPanel({
selectedDiff: externalSelectedDiff,
openFileRequest,
onDiffHandled,
onFileOpenHandled,
onActiveFileChange,
sessionId = null,
}: TaskCenterPanelProps) {
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const activeSession = useAppStore((state) =>
activeSessionId ? state.taskSessions.items[activeSessionId] ?? null : null
);
const setTaskSession = useAppStore((state) => state.setTaskSession);
// Check if agent is currently working
const isAgentWorking = activeSession?.state === 'STARTING' || activeSession?.state === 'RUNNING';
// Check if session is in passthrough mode by looking at the profile snapshot
const isPassthroughMode = useMemo(() => {
if (!activeSession?.agent_profile_snapshot) return false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const snapshot = activeSession.agent_profile_snapshot as any;
return snapshot?.cli_passthrough === true;
}, [activeSession?.agent_profile_snapshot]);
// Check if we should show the approve button
// Show when session has a review_status that is not approved (meaning it's in a review step)
// Also hide while agent is working to prevent premature approval
// Use truthy check to handle null, undefined, and empty string (backend may send "" when clearing)
const showApproveButton =
!!activeSession?.review_status &&
activeSession.review_status !== 'approved' &&
!isAgentWorking;
// Approve handler - moves session to next workflow step
const handleApprove = useCallback(async () => {
if (!activeSessionId || !activeTaskId) return;
try {
const response = await approveSessionAction(activeSessionId);
// Update the session in the store so the review panel closes
if (response?.session) {
setTaskSession(response.session);
}
// Check if the new step has auto_start_agent enabled
if (response?.workflow_step?.auto_start_agent) {
const client = getWebSocketClient();
if (client) {
client.send({
type: 'request',
action: 'orchestrator.start',
payload: {
task_id: activeTaskId,
session_id: activeSessionId,
workflow_step_id: response.workflow_step.id,
},
});
}
}
} catch (error) {
console.error('Failed to approve session:', error);
}
}, [activeSessionId, activeTaskId, setTaskSession]);
// Get git status and commits to determine if there are changes
const gitStatus = useSessionGitStatus(activeSessionId);
const { commits } = useSessionCommits(activeSessionId);
const hasChanges = useMemo(() => {
const hasUncommittedChanges = gitStatus?.files && Object.keys(gitStatus.files).length > 0;
const hasCommits = commits && commits.length > 0;
return hasUncommittedChanges || hasCommits;
}, [gitStatus, commits]);
// Initialize tab - restored from sessionStorage or default to 'chat'
const [leftTab, setLeftTab] = useState(() => {
// Try to restore from session-specific storage on initial render
if (typeof window !== 'undefined' && activeSessionId) {
const savedTab = getActiveTabForSession(activeSessionId, 'chat');
// Only return non-file tabs synchronously, file tabs need async content loading
if (savedTab === 'chat' || savedTab === 'plan' || savedTab === 'changes') {
return savedTab;
}
}
return 'chat';
});
// If current tab is 'changes' but there are no changes, switch to 'chat'
useEffect(() => {
if (leftTab === 'changes' && !hasChanges) {
setLeftTab('chat');
}
}, [leftTab, hasChanges]);
// Request changes state - triggers focus and tooltip on chat input
const [showRequestChangesTooltip, setShowRequestChangesTooltip] = useState(false);
// Request changes handler - switches to chat and focuses input
const handleRequestChanges = useCallback(() => {
// Switch to chat tab
setLeftTab('chat');
// Show tooltip on input
setShowRequestChangesTooltip(true);
// Auto-hide tooltip after 5 seconds
setTimeout(() => setShowRequestChangesTooltip(false), 5000);
}, []);
const [selectedDiff, setSelectedDiff] = useState<SelectedDiff | null>(null);
const [openFileTabs, setOpenFileTabs] = useState<OpenFileTab[]>([]);
const [savingFiles, setSavingFiles] = useState<Set<string>>(new Set());
const restoredTabsRef = useRef<string | null>(null);
const restorationInProgressRef = useRef<boolean>(false);
const { toast } = useToast();
// Save current active tab for the current session before switching
const prevSessionRef = useRef<string | null>(null);
useEffect(() => {
// On unmount or before session change, save the current tab for the previous session
return () => {
if (prevSessionRef.current && leftTab) {
setActiveTabForSession(prevSessionRef.current, leftTab);
}
};
}, [leftTab]);
// Restore file tabs and active tab from sessionStorage on mount/session change
useEffect(() => {
if (!activeSessionId) return;
// When session changes, always clear existing tabs first and reset state
if (restoredTabsRef.current !== activeSessionId) {
// Save the active tab for the OLD session before switching
if (prevSessionRef.current && prevSessionRef.current !== activeSessionId) {
setActiveTabForSession(prevSessionRef.current, leftTab);
}
restoredTabsRef.current = activeSessionId;
prevSessionRef.current = activeSessionId;
restorationInProgressRef.current = false;
// Clear existing file tabs immediately when session changes
setOpenFileTabs([]);
} else if (restorationInProgressRef.current) {
// Already restoring for this session
return;
} else {
// Already restored for this session
return;
}
const savedTabs = getOpenFileTabs(activeSessionId);
const savedActiveTab = getActiveTabForSession(activeSessionId, 'chat');
// If no file tabs to load, just restore the active tab
if (savedTabs.length === 0) {
// Only set if it's a valid non-file tab
if (savedActiveTab === 'chat' || savedActiveTab === 'plan' || savedActiveTab === 'changes') {
setLeftTab(savedActiveTab);
} else {
setLeftTab('chat');
}
return;
}
// Mark restoration as in progress
restorationInProgressRef.current = true;
// Load content for each saved tab with retry for WebSocket client
const loadTabs = async (retryCount = 0): Promise<void> => {
const maxRetries = 5;
const retryDelay = 200;
const client = getWebSocketClient();
if (!client) {
if (retryCount < maxRetries) {
setTimeout(() => loadTabs(retryCount + 1), retryDelay);
return;
}
restorationInProgressRef.current = false;
return;
}
// Verify we're still restoring for the same session
if (restoredTabsRef.current !== activeSessionId) {
restorationInProgressRef.current = false;
return;
}
const loadedTabs: OpenFileTab[] = [];
for (const savedTab of savedTabs) {
try {
const response = await requestFileContent(client, activeSessionId, savedTab.path);
const hash = await calculateHash(response.content);
loadedTabs.push({
path: savedTab.path,
name: savedTab.name,
content: response.content,
originalContent: response.content,
originalHash: hash,
isDirty: false,
});
} catch {
// Failed to restore tab, skip it
}
}
// Verify session hasn't changed
if (restoredTabsRef.current !== activeSessionId) {
restorationInProgressRef.current = false;
return;
}
if (loadedTabs.length > 0) {
setOpenFileTabs(loadedTabs);
// Restore active tab - check if it's a file tab that was loaded
if (savedActiveTab.startsWith('file:')) {
const filePath = savedActiveTab.replace('file:', '');
const tabExists = loadedTabs.some(t => t.path === filePath);
if (tabExists) {
// Use setTimeout to ensure React has processed setOpenFileTabs
setTimeout(() => {
setLeftTab(savedActiveTab);
restorationInProgressRef.current = false;
}, 0);
} else {
setLeftTab('chat');
restorationInProgressRef.current = false;
}
} else {
setLeftTab(savedActiveTab);
restorationInProgressRef.current = false;
}
} else {
// No tabs could be loaded, fall back to non-file tab
if (savedActiveTab === 'chat' || savedActiveTab === 'plan' || savedActiveTab === 'changes') {
setLeftTab(savedActiveTab);
} else {
setLeftTab('chat');
}
restorationInProgressRef.current = false;
}
};
void loadTabs();
// eslint-disable-next-line react-hooks/exhaustive-deps -- leftTab is intentionally excluded to prevent re-running on tab changes
}, [activeSessionId]);
// Persist open file tabs to sessionStorage when they change
useEffect(() => {
if (!activeSessionId) return;
const tabsToSave = openFileTabs.map(({ path, name }) => ({ path, name }));
saveOpenFileTabs(activeSessionId, tabsToSave);
}, [activeSessionId, openFileTabs]);
// Persist active tab whenever it changes (not just via handleTabChange)
useEffect(() => {
if (!activeSessionId) return;
// Skip during restoration to avoid overwriting with intermediate values
if (restorationInProgressRef.current) {
return;
}
setActiveTabForSession(activeSessionId, leftTab);
}, [activeSessionId, leftTab]);
// Notify parent when the active file changes
useEffect(() => {
if (leftTab.startsWith('file:')) {
const filePath = leftTab.replace('file:', '');
onActiveFileChange?.(filePath);
} else {
onActiveFileChange?.(null);
}
}, [leftTab, onActiveFileChange]);
// Track plan updates for notification dot
const plan = useAppStore((state) =>
activeTaskId ? state.taskPlans.byTaskId[activeTaskId] : null
);
// Derive notification state: show dot if plan was updated by agent and we haven't seen it
const hasUnseenPlanUpdate = useMemo(() => {
if (!activeTaskId || !plan || leftTab === 'plan') return false;
if (plan.created_by !== 'agent') return false;
const lastSeen = getPlanLastSeen(activeTaskId);
return plan.updated_at !== lastSeen;
}, [activeTaskId, plan, leftTab]);
// Handle tab change - mark plan as seen when switching to plan tab
const handleTabChange = useCallback((tab: string) => {
// If switching to plan tab, mark current plan as seen in localStorage
if (tab === 'plan' && activeTaskId && plan?.updated_at) {
setPlanLastSeen(activeTaskId, plan.updated_at);
}
setLeftTab(tab);
// Persist tab selection per-session (for all tabs including file tabs)
if (activeSessionId) {
setActiveTabForSession(activeSessionId, tab);
}
}, [activeTaskId, activeSessionId, plan]);
// Handle external diff selection
useEffect(() => {
if (externalSelectedDiff) {
queueMicrotask(() => {
setSelectedDiff(externalSelectedDiff);
setLeftTab('changes');
onDiffHandled();
});
}
}, [externalSelectedDiff, onDiffHandled]);
// Handle external file open request
useEffect(() => {
if (openFileRequest) {
queueMicrotask(async () => {
// Calculate hash for the file if not already present
const hash = openFileRequest.originalHash || await calculateHash(openFileRequest.content);
const fileWithHash: OpenFileTab = {
...openFileRequest,
originalContent: openFileRequest.originalContent || openFileRequest.content,
originalHash: hash,
isDirty: openFileRequest.isDirty ?? false,
};
setOpenFileTabs((prev) => {
// If file is already open, just switch to it
if (prev.some((t) => t.path === fileWithHash.path)) {
return prev;
}
// Add new tab (LRU eviction at max 4)
const maxTabs = 4;
const newTabs = prev.length >= maxTabs ? [...prev.slice(1), fileWithHash] : [...prev, fileWithHash];
return newTabs;
});
setLeftTab(`file:${fileWithHash.path}`);
onFileOpenHandled();
});
}
}, [openFileRequest, onFileOpenHandled]);
const handleCloseFileTab = useCallback((path: string) => {
setOpenFileTabs((prev) => prev.filter((t) => t.path !== path));
// If closing the active tab, switch to chat
if (leftTab === `file:${path}`) {
handleTabChange('chat');
}
}, [leftTab, handleTabChange]);
// Handler for opening files from chat (tool read messages)
const handleOpenFileFromChat = useCallback(async (filePath: string) => {
const client = getWebSocketClient();
const currentSessionId = activeSessionId;
if (!client || !currentSessionId) return;
try {
const response: FileContentResponse = await requestFileContent(client, currentSessionId, filePath);
const fileName = filePath.split('/').pop() || filePath;
const hash = await calculateHash(response.content);
setOpenFileTabs((prev) => {
// If file is already open, just switch to it
if (prev.some((t) => t.path === filePath)) {
return prev;
}
// Add new tab (LRU eviction at max 4)
const maxTabs = 4;
const newTab: OpenFileTab = {
path: filePath,
name: fileName,
content: response.content,
originalContent: response.content,
originalHash: hash,
isDirty: false,
};
const newTabs = prev.length >= maxTabs ? [...prev.slice(1), newTab] : [...prev, newTab];
return newTabs;
});
setLeftTab(`file:${filePath}`);
} catch (error) {
console.error('Failed to open file from chat:', error);
}
}, [activeSessionId]);
// Handler for file content changes
const handleFileChange = useCallback((path: string, newContent: string) => {
setOpenFileTabs((prev) =>
prev.map((tab) =>
tab.path === path
? { ...tab, content: newContent, isDirty: newContent !== tab.originalContent }
: tab
)
);
}, []);
// Handler for saving file
const handleFileSave = useCallback(async (path: string) => {
const tab = openFileTabs.find((t) => t.path === path);
if (!tab || !tab.isDirty) return;
const client = getWebSocketClient();
if (!client || !activeSessionId) return;
setSavingFiles((prev) => new Set(prev).add(path));
try {
const diff = generateUnifiedDiff(tab.originalContent, tab.content, tab.name);
const response = await updateFileContent(client, activeSessionId, path, diff, tab.originalHash);
if (response.success && response.new_hash) {
setOpenFileTabs((prev) =>
prev.map((t) =>
t.path === path
? { ...t, originalContent: t.content, originalHash: response.new_hash!, isDirty: false }
: t
)
);
toast({
title: 'File saved',
description: `${tab.name} has been saved successfully.`,
});
} else {
toast({
title: 'Save failed',
description: response.error || 'Failed to save file',
variant: 'error',
});
}
} catch (error) {
console.error('Failed to save file:', error);
toast({
title: 'Save failed',
description: error instanceof Error ? error.message : 'An error occurred while saving the file',
variant: 'error',
});
} finally {
setSavingFiles((prev) => {
const next = new Set(prev);
next.delete(path);
return next;
});
}
}, [openFileTabs, activeSessionId, toast]);
// Handler for deleting file from editor
const handleFileDelete = useCallback(async (path: string) => {
const client = getWebSocketClient();
if (!client || !activeSessionId) return;
try {
const response = await deleteFile(client, activeSessionId, path);
if (response.success) {
// Close the tab
handleCloseFileTab(path);
} else {
toast({
title: 'Delete failed',
description: response.error || 'Failed to delete file',
variant: 'error',
});
}
} catch (error) {
console.error('Failed to delete file:', error);
toast({
title: 'Delete failed',
description: error instanceof Error ? error.message : 'An error occurred while deleting the file',
variant: 'error',
});
}
}, [activeSessionId, handleCloseFileTab, toast]);
const tabs: SessionTab[] = useMemo(() => {
const staticTabs: SessionTab[] = [
{
id: 'plan',
label: 'Plan',
icon: hasUnseenPlanUpdate ? (
<div className="relative">
<IconSparkles className="h-3.5 w-3.5 text-amber-500 dark:text-amber-400 animate-pulse" />
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-amber-500 dark:bg-amber-400 animate-ping" />
</div>
) : undefined,
},
// Only show "All changes" tab if there are changes
...(hasChanges ? [{ id: 'changes', label: 'All changes' }] : []),
{ id: 'chat', label: 'Chat' },
];
const fileTabs: SessionTab[] = openFileTabs.map((tab) => {
return {
id: `file:${tab.path}`,
label: tab.isDirty ? `${tab.name} *` : tab.name,
icon: tab.isDirty ? <span className="h-2 w-2 rounded-full bg-yellow-500" /> : undefined,
closable: true,
onClose: (e) => {
e.stopPropagation();
handleCloseFileTab(tab.path);
},
className: 'cursor-pointer group gap-1.5 data-[state=active]:bg-muted',
};
});
return [...staticTabs, ...fileTabs];
}, [openFileTabs, handleCloseFileTab, hasUnseenPlanUpdate, hasChanges]);
return (
<SessionPanel borderSide="right" margin="right">
<SessionTabs
tabs={tabs}
activeTab={leftTab}
onTabChange={handleTabChange}
separatorAfterIndex={openFileTabs.length > 0 ? (hasChanges ? 2 : 1) : undefined}
className="flex-1 min-h-0 flex flex-col gap-2"
rightContent={
showApproveButton ? (
<div className="flex items-center gap-0.5">
<Button
type="button"
size="sm"
className="h-6 gap-1.5 px-2.5 cursor-pointer bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-medium rounded-r-none border-r border-emerald-700/30"
onClick={handleApprove}
>
<IconCheck className="h-3.5 w-3.5" />
Approve
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="sm"
className="h-6 w-6 p-0 cursor-pointer bg-emerald-600 hover:bg-emerald-700 text-white rounded-l-none"
>
<IconChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={handleApprove} className="cursor-pointer">
<IconCheck className="h-4 w-4 mr-2" />
Approve and continue
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleRequestChanges}
className="cursor-pointer text-amber-600 dark:text-amber-500"
>
<IconX className="h-4 w-4 mr-2" />
Request changes
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
) : undefined
}
>
<TabsContent value="plan" className="flex-1 min-h-0" forceMount style={{ display: leftTab === 'plan' ? undefined : 'none' }}>
<TaskPlanPanel
taskId={activeTaskId}
visible={leftTab === 'plan'}
/>
</TabsContent>
<TabsContent value="changes" className="flex-1 min-h-0">
<TaskChangesPanel
selectedDiff={selectedDiff}
onClearSelected={() => setSelectedDiff(null)}
onOpenFile={handleOpenFileFromChat}
/>
</TabsContent>
<TabsContent value="chat" className="flex flex-col min-h-0 flex-1" style={{ minHeight: '200px' }}>
{activeTaskId ? (
isPassthroughMode ? (
<div className="flex-1 min-h-0 h-full" style={{ minHeight: '150px' }}>
<PassthroughTerminal key={activeSessionId} sessionId={sessionId} />
</div>
) : (
<TaskChatPanel
sessionId={sessionId}
onOpenFile={handleOpenFileFromChat}
showRequestChangesTooltip={showRequestChangesTooltip}
onRequestChangesTooltipDismiss={() => setShowRequestChangesTooltip(false)}
onOpenFileAtLine={(filePath) => {
// Open the file in editor tab (or switch to it)
handleOpenFileFromChat(filePath);
}}
/>
)
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
No task selected
</div>
)}
</TabsContent>
{openFileTabs.map((tab) => (
<TabsContent key={tab.path} value={`file:${tab.path}`} className="flex-1 min-h-0">
<FileEditorContent
path={tab.path}
content={tab.content}
originalContent={tab.originalContent}
isDirty={tab.isDirty}
isSaving={savingFiles.has(tab.path)}
sessionId={activeSessionId || undefined}
enableComments={!!activeSessionId}
onChange={(newContent) => handleFileChange(tab.path, newContent)}
onSave={() => handleFileSave(tab.path)}
onDelete={() => handleFileDelete(tab.path)}
/>
</TabsContent>
))}
</SessionTabs>
</SessionPanel>
);
});