Files
kandev/apps/web/components/task/document/document-panel.tsx
T
2026-02-16 13:48:58 +00:00

225 lines
6.9 KiB
TypeScript

'use client';
import { memo, useEffect, useMemo, useState, useCallback } from 'react';
import { IconX, IconChevronDown, IconPlayerPlay, IconPlus } from '@tabler/icons-react';
import { TabsContent } from '@kandev/ui/tabs';
import { Button } from '@kandev/ui/button';
import { SessionPanel, SessionPanelContent } from '@kandev/ui/pannel-session';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@kandev/ui/dropdown-menu';
import { useAppStore } from '@/components/state-provider';
import { useLayoutStore } from '@/lib/state/layout-store';
// closeDocument kept for mobile/tablet backward compat
import { getWebSocketClient } from '@/lib/ws/connection';
import { useTask } from '@/hooks/use-task';
import { SessionTabs, type SessionTab } from '@/components/session-tabs';
import { TaskPlanPanel } from '../task-plan-panel';
import { TaskCreateDialog } from '@/components/task-create-dialog';
import type { ActiveDocument } from '@/lib/state/slices/ui/types';
type DocumentPanelProps = {
sessionId: string | null;
};
export const DocumentPanel = memo(function DocumentPanel({ sessionId }: DocumentPanelProps) {
const activeDocument = useAppStore((state) =>
sessionId ? state.documentPanel.activeDocumentBySessionId[sessionId] ?? null : null
);
const setActiveDocument = useAppStore((state) => state.setActiveDocument);
const closeDocument = useLayoutStore((state) => state.closeDocument);
const handleClose = () => {
if (!sessionId) return;
closeDocument(sessionId);
setActiveDocument(sessionId, null);
};
// Auto-close document layout when no document is active (e.g. stale localStorage)
useEffect(() => {
if (!activeDocument && sessionId) {
closeDocument(sessionId);
}
}, [activeDocument, sessionId, closeDocument]);
const tabs: SessionTab[] = useMemo(() => {
if (!activeDocument) return [];
if (activeDocument.type === 'plan') {
return [{ id: 'plan', label: 'Plan' }];
}
return [{ id: 'file', label: activeDocument.name }];
}, [activeDocument]);
const activeTab = activeDocument?.type === 'plan' ? 'plan' : 'file';
if (!activeDocument) {
return null;
}
const isPlan = activeDocument.type === 'plan';
const headerActions = (
<div className="flex items-center gap-0.5">
{isPlan && (
<ImplementPlanDropdown sessionId={sessionId} onClose={handleClose} />
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="cursor-pointer"
onClick={handleClose}
>
<IconX className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Close document panel</TooltipContent>
</Tooltip>
</div>
);
return (
<SessionPanel borderSide="right" margin="right">
<SessionTabs
tabs={tabs}
activeTab={activeTab}
onTabChange={() => { }}
className="flex-1 min-h-0 flex flex-col gap-2"
rightContent={headerActions}
>
<TabsContent value="plan" className="flex-1 min-h-0">
<DocumentPlanContent />
</TabsContent>
<TabsContent value="file" className="flex-1 min-h-0">
<DocumentFileContent doc={activeDocument} />
</TabsContent>
</SessionTabs>
</SessionPanel>
);
});
/** Dropdown button for implementing the plan */
const ImplementPlanDropdown = memo(function ImplementPlanDropdown({
sessionId,
onClose,
}: {
sessionId: string | null;
onClose: () => void;
}) {
const taskId = useAppStore((state) => state.tasks.activeTaskId);
const task = useTask(taskId);
const plan = useAppStore((state) =>
taskId ? state.taskPlans.byTaskId[taskId] ?? null : null
);
const [showNewSessionDialog, setShowNewSessionDialog] = useState(false);
const handleImplement = useCallback(async () => {
if (!taskId || !sessionId || !plan?.content) return;
const client = getWebSocketClient();
if (!client) return;
// Close plan mode first
onClose();
// Send the plan as an implementation prompt (not in plan mode)
try {
await client.request(
'message.add',
{
task_id: taskId,
session_id: sessionId,
content: `Implement the following plan:\n\n${plan.content}`,
},
10000
);
} catch (err) {
console.error('Failed to send implement plan message:', err);
}
}, [taskId, sessionId, plan, onClose]);
const handleImplementNewSession = useCallback(() => {
// Close plan mode and open the new session dialog
onClose();
setShowNewSessionDialog(true);
}, [onClose]);
return (
<>
<div className="flex items-center">
<Button
variant="ghost"
size="sm"
className="h-6 gap-1 px-2 cursor-pointer text-xs hover:bg-muted/40"
onClick={handleImplement}
disabled={!plan?.content}
>
<IconPlayerPlay className="h-3 w-3" />
Implement
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-5 p-0 cursor-pointer hover:bg-muted/40"
disabled={!plan?.content}
>
<IconChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuItem onClick={handleImplementNewSession} className="cursor-pointer">
<IconPlus className="h-4 w-4 mr-2 shrink-0 self-start mt-0.5" />
<div>
<div>Implement in a new session</div>
<div className="text-[11px] text-muted-foreground font-normal">Starts fresh with a clean context window</div>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<TaskCreateDialog
open={showNewSessionDialog}
onOpenChange={setShowNewSessionDialog}
mode="session"
workspaceId={null}
workflowId={null}
defaultStepId={null}
steps={[]}
taskId={taskId}
initialValues={{
title: task?.title ?? '',
description: plan?.content ? `Implement the following plan:\n\n${plan.content}` : '',
}}
/>
</>
);
});
const DocumentPlanContent = memo(function DocumentPlanContent() {
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
return <TaskPlanPanel taskId={activeTaskId} visible />;
});
const DocumentFileContent = memo(function DocumentFileContent({
doc,
}: {
doc: ActiveDocument;
}) {
if (doc.type === 'plan') return null;
return (
<SessionPanelContent>
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
File viewer: {doc.path}
</div>
</SessionPanelContent>
);
});