Files
kandev/apps/web/components/task/document/document-panel.tsx
T
2026-02-19 22:18:01 +00:00

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