Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/__tests__/unit/directory-existence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { after, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'fs';
import os from 'os';
import path from 'path';

import { findMissingDirectories } from '../../lib/directory-existence';

describe('findMissingDirectories', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codepilot-dir-exists-'));
const existingDir = path.join(tmpDir, 'existing');
const anotherDir = path.join(tmpDir, 'another');
const missingDir = path.join(tmpDir, 'missing');

fs.mkdirSync(existingDir, { recursive: true });
fs.mkdirSync(anotherDir, { recursive: true });

after(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it('should return only missing directories in stable order', async () => {
const result = await findMissingDirectories([
existingDir,
missingDir,
anotherDir,
]);

assert.deepEqual(result, [missingDir]);
});

it('should deduplicate repeated directories before checking', async () => {
const result = await findMissingDirectories([
missingDir,
missingDir,
existingDir,
existingDir,
]);

assert.deepEqual(result, [missingDir]);
});
});
19 changes: 19 additions & 0 deletions src/app/api/files/exists/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
import { findMissingDirectories } from '@/lib/directory-existence';

export async function POST(request: Request) {
try {
const body = await request.json();
const dirs = Array.isArray(body?.dirs)
? body.dirs.filter((dir: unknown): dir is string => typeof dir === 'string')
: [];

const missingDirs = await findMissingDirectories(dirs);
return NextResponse.json({ missingDirs });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to check directories' },
{ status: 500 }
);
}
}
106 changes: 103 additions & 3 deletions src/components/layout/ChatListPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
FolderOpenIcon,
AiUserIcon,
} from "@hugeicons/core-free-icons";
import { Columns2, X } from "lucide-react";
import { AlertTriangle, Columns2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
Expand Down Expand Up @@ -117,7 +117,7 @@ function groupSessionsByProject(sessions: ChatSession[]): ProjectGroup[] {
export function ChatListPanel({ open, width }: ChatListPanelProps) {
const pathname = usePathname();
const router = useRouter();
const { streamingSessionId, pendingApprovalSessionId, activeStreamingSessions, pendingApprovalSessionIds, workingDirectory, sessionId: currentSessionId } = usePanel();
const { streamingSessionId, pendingApprovalSessionId, activeStreamingSessions, pendingApprovalSessionIds, workingDirectory } = usePanel();
const { splitSessions, isSplitActive, activeColumnId, addToSplit, removeFromSplit, setActiveColumn, isInSplit } = useSplit();
const { t } = useTranslation();
const { isElectron, openNativePicker } = useNativeFolderPicker();
Expand All @@ -132,6 +132,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
);
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
const [creatingChat, setCreatingChat] = useState(false);
const [missingDirs, setMissingDirs] = useState<Set<string>>(new Set());
const { workspacePath } = useAssistantWorkspace();

/** Read current model + provider_id from localStorage for new session creation */
Expand Down Expand Up @@ -211,7 +212,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
} finally {
setCreatingChat(false);
}
}, [router, workingDirectory, openFolderPicker]);
}, [router, workingDirectory, openFolderPicker, getCurrentModelAndProvider]);

const toggleProject = useCallback((wd: string) => {
setCollapsedProjects((prev) => {
Expand Down Expand Up @@ -330,6 +331,88 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
}
};

const uniqueDirsKey = useMemo(
() =>
JSON.stringify(
[...new Set(sessions.map((session) => session.working_directory).filter(Boolean))].sort()
),
[sessions]
);
const dirCheckTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const dirCheckAbortRef = useRef<AbortController | null>(null);
const dirCheckRequestIdRef = useRef(0);

const checkMissingDirs = useCallback(() => {
const dirs: string[] = JSON.parse(uniqueDirsKey);
if (dirs.length === 0) {
if (mountedRef.current) {
setMissingDirs(new Set());
}
return;
}

if (dirCheckTimerRef.current) {
clearTimeout(dirCheckTimerRef.current);
}
dirCheckAbortRef.current?.abort();
const requestId = dirCheckRequestIdRef.current + 1;
dirCheckRequestIdRef.current = requestId;

dirCheckTimerRef.current = setTimeout(async () => {
const abortController = new AbortController();
dirCheckAbortRef.current = abortController;

let missing = new Set<string>();
try {
const res = await fetch("/api/files/exists", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dirs }),
signal: abortController.signal,
});

if (res.ok) {
const data = await res.json();
missing = new Set<string>(Array.isArray(data.missingDirs) ? data.missingDirs : []);
} else {
missing = new Set(dirs);
}
} catch (error) {
if ((error as Error).name === "AbortError") {
return;
}
missing = new Set(dirs);
}

if (mountedRef.current && requestId === dirCheckRequestIdRef.current) {
setMissingDirs(missing);
}
}, 1000);
}, [uniqueDirsKey]);

useEffect(() => {
mountedRef.current = true;
checkMissingDirs();

const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
checkMissingDirs();
}
};

document.addEventListener("visibilitychange", handleVisibilityChange);

return () => {
mountedRef.current = false;
if (dirCheckTimerRef.current) {
clearTimeout(dirCheckTimerRef.current);
}
dirCheckAbortRef.current?.abort();
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [checkMissingDirs]);

const isSearching = searchQuery.length > 0;

const splitSessionIds = useMemo(
Expand Down Expand Up @@ -569,6 +652,23 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
{workspacePath && group.workingDirectory === workspacePath && (
<HugeiconsIcon icon={AiUserIcon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
{group.workingDirectory && missingDirs.has(group.workingDirectory) && (
<Tooltip>
<TooltipTrigger asChild>
<span
tabIndex={0}
aria-label={t("chatList.dirNotFound")}
className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-amber-500"
>
<AlertTriangle className="h-3.5 w-3.5" />
<span className="sr-only">{t("chatList.dirNotFound")}</span>
</span>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs">{t("chatList.dirNotFound")}</p>
</TooltipContent>
</Tooltip>
)}
{/* New chat in project button (on hover) */}
{group.workingDirectory !== "" && (
<Tooltip>
Expand Down
1 change: 1 addition & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const en = {
'chatList.noSessions': 'No sessions yet',
'chatList.importFromCli': 'Import from Claude Code',
'chatList.addProjectFolder': 'Add project folder',
'chatList.dirNotFound': 'Project directory no longer exists',
'chatList.threads': 'Threads',

// ── Message list ────────────────────────────────────────────
Expand Down
1 change: 1 addition & 0 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const zh: Record<TranslationKey, string> = {
'chatList.noSessions': '暂无会话',
'chatList.importFromCli': '从 Claude Code 导入',
'chatList.addProjectFolder': '添加项目文件夹',
'chatList.dirNotFound': '项目目录已不存在',
'chatList.threads': '对话列表',

// ── Message list ────────────────────────────────────────────
Expand Down
19 changes: 19 additions & 0 deletions src/lib/directory-existence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import fs from 'fs/promises';
import path from 'path';

export async function findMissingDirectories(directories: string[]): Promise<string[]> {
const uniqueDirectories = [...new Set(directories.filter(Boolean).map((dir) => path.resolve(dir)))];
const missing: string[] = [];

await Promise.all(
uniqueDirectories.map(async (dir) => {
try {
await fs.access(dir);
} catch {
missing.push(dir);
}
})
);

return uniqueDirectories.filter((dir) => missing.includes(dir));
}
Loading