"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { IconListCheck, IconFile, IconFolder, IconSearch, IconAt } from "@tabler/icons-react";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Checkbox } from "@kandev/ui/checkbox";
import { Input } from "@kandev/ui/input";
import { getWebSocketClient } from "@/lib/ws/connection";
import { searchWorkspaceFiles } from "@/lib/ws/workspace-files";
import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts";
import { isDirectory, getFileName } from "@/lib/utils/file-path";
import type { ContextFile } from "@/lib/state/context-files-store";
type ContextPopoverProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
trigger: React.ReactNode;
sessionId: string | null;
/** Whether plan is included as context (from context files store, not plan panel) */
planContextEnabled: boolean;
contextFiles: ContextFile[];
onToggleFile: (file: ContextFile) => void;
};
const FILE_SEARCH_DEBOUNCE = 300;
const PLAN_CONTEXT_FILE: ContextFile = { path: "plan:context", name: "Plan", pinned: true };
function FileResultsList({
isLoading,
fileResults,
query,
filteredPromptsEmpty,
isFileSelected,
onToggle,
}: {
isLoading: boolean;
fileResults: string[];
query: string;
filteredPromptsEmpty: boolean;
isFileSelected: (path: string) => boolean;
onToggle: (filePath: string) => void;
}) {
if (isLoading) {
return
Loading...
;
}
if (fileResults.length === 0 && query && filteredPromptsEmpty) {
return (
No results found
);
}
return (
<>
{fileResults.map((filePath) => {
const isDir = isDirectory(filePath);
const name = getFileName(filePath);
const parent = filePath.slice(0, filePath.length - name.length);
return (
onToggle(filePath)}
>
onToggle(filePath)}
className="h-3.5 w-3.5"
/>
{isDir ? (
) : (
)}
{name}
{parent && (
{parent}
)}
);
})}
>
);
}
type PromptsSectionProps = {
query: string;
prompts: { id: string; name: string; content?: string }[];
contextFiles: ContextFile[];
onToggleFile: (file: ContextFile) => void;
};
function PromptsSection({ query, prompts, contextFiles, onToggleFile }: PromptsSectionProps) {
if (prompts.length === 0) return null;
return (
<>
{!query && (
)}
{prompts.map((prompt) => (
onToggleFile({ path: `prompt:${prompt.id}`, name: prompt.name, pinned: true })
}
>
f.path === `prompt:${prompt.id}`)}
onCheckedChange={() =>
onToggleFile({ path: `prompt:${prompt.id}`, name: prompt.name, pinned: true })
}
className="h-3.5 w-3.5"
/>
{prompt.name}
{prompt.content && (
{prompt.content.length > 60 ? prompt.content.slice(0, 60) + "..." : prompt.content}
)}
))}
>
);
}
function useContextPopoverState(open: boolean, sessionId: string | null) {
const [query, setQuery] = useState("");
const [fileResults, setFileResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const searchTimeoutRef = useRef | null>(null);
const inputRef = useRef(null);
/* eslint-disable react-hooks/set-state-in-effect -- resetting state on open/close is intentional */
useEffect(() => {
if (open) {
requestAnimationFrame(() => inputRef.current?.focus());
} else {
setQuery("");
setFileResults([]);
}
}, [open]);
/* eslint-enable react-hooks/set-state-in-effect */
/* eslint-disable react-hooks/set-state-in-effect -- loading state sync is intentional for UX */
useEffect(() => {
if (!open || !sessionId) return;
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
const delay = query === "" ? 0 : FILE_SEARCH_DEBOUNCE;
setIsLoading(true);
let cancelled = false;
searchTimeoutRef.current = setTimeout(async () => {
try {
const client = getWebSocketClient();
if (!client) {
if (!cancelled) {
setFileResults([]);
setIsLoading(false);
}
return;
}
const response = await searchWorkspaceFiles(client, sessionId, query || "", 20);
if (!cancelled) setFileResults(response.files || []);
} catch {
if (!cancelled) setFileResults([]);
}
if (!cancelled) setIsLoading(false);
}, delay);
return () => {
cancelled = true;
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
};
}, [open, sessionId, query]);
/* eslint-enable react-hooks/set-state-in-effect */
return { query, setQuery, fileResults, isLoading, inputRef };
}
function ContextPopoverList({
query,
fileResults,
isLoading,
planContextEnabled,
filteredPrompts,
contextFiles,
onToggleFile,
isFileSelected,
handleToggleFile,
}: {
query: string;
fileResults: string[];
isLoading: boolean;
planContextEnabled: boolean;
filteredPrompts: { id: string; name: string; content?: string }[];
contextFiles: ContextFile[];
onToggleFile: (file: ContextFile) => void;
isFileSelected: (path: string) => boolean;
handleToggleFile: (filePath: string) => void;
}) {
return (
{!query && (
onToggleFile(PLAN_CONTEXT_FILE)}
>
onToggleFile(PLAN_CONTEXT_FILE)}
className="h-3.5 w-3.5"
/>
Plan
{planContextEnabled && (
ACTIVE
)}
)}
{!query && fileResults.length > 0 && (
)}
);
}
export function ContextPopover({
open,
onOpenChange,
trigger,
sessionId,
planContextEnabled,
contextFiles,
onToggleFile,
}: ContextPopoverProps) {
const { query, setQuery, fileResults, isLoading, inputRef } = useContextPopoverState(
open,
sessionId,
);
const { prompts } = useCustomPrompts();
const handleToggleFile = useCallback(
(filePath: string) => {
const name = getFileName(filePath);
onToggleFile({ path: filePath, name, pinned: true });
},
[onToggleFile],
);
const isFileSelected = useCallback(
(path: string) => contextFiles.some((f) => f.path === path),
[contextFiles],
);
const filteredPrompts = query
? prompts.filter((p) => p.name.toLowerCase().includes(query.toLowerCase()))
: prompts;
return (
{trigger}
e.preventDefault()}
>
Context
ยท Select files and prompts to include
);
}