fix(web): honor window.__KANDEV_DEBUG so make start-debug logs surface (#991)
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import { requestFileTree, requestFileContent } from "@/lib/ws/workspace-files";
|
||||
import type { FileTreeNode, FileContentResponse, OpenFileTab } from "@/lib/types/backend";
|
||||
import { getFilesPanelScrollPosition, setFilesPanelScrollPosition } from "@/lib/local-storage";
|
||||
import type { useToast } from "@/components/toast-provider";
|
||||
import type { useFileBrowserTree } from "./file-browser-hooks";
|
||||
|
||||
/** Hook for scroll position persistence in the file browser. */
|
||||
export function useScrollPersistence(
|
||||
sessionId: string,
|
||||
isTreeLoaded: boolean,
|
||||
scrollAreaRef: React.RefObject<HTMLDivElement | null>,
|
||||
tree: FileTreeNode | null,
|
||||
) {
|
||||
const scrollSaveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasRestoredScrollRef = useRef<string | null>(null);
|
||||
|
||||
// Restore scroll position after tree loads
|
||||
useEffect(() => {
|
||||
if (!isTreeLoaded || hasRestoredScrollRef.current === sessionId) return;
|
||||
const savedScroll = getFilesPanelScrollPosition(sessionId);
|
||||
if (savedScroll > 0 && scrollAreaRef.current) {
|
||||
const viewport = scrollAreaRef.current.querySelector("[data-radix-scroll-area-viewport]");
|
||||
if (viewport) {
|
||||
viewport.scrollTop = savedScroll;
|
||||
hasRestoredScrollRef.current = sessionId;
|
||||
}
|
||||
} else {
|
||||
hasRestoredScrollRef.current = sessionId;
|
||||
}
|
||||
}, [isTreeLoaded, sessionId, scrollAreaRef]);
|
||||
|
||||
// Attach scroll listener to ScrollArea viewport
|
||||
useEffect(() => {
|
||||
const el = scrollAreaRef.current;
|
||||
if (!el) return;
|
||||
const viewport = el.querySelector("[data-radix-scroll-area-viewport]");
|
||||
if (!viewport) return;
|
||||
const onScroll = (event: Event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (scrollSaveTimeoutRef.current) clearTimeout(scrollSaveTimeoutRef.current);
|
||||
scrollSaveTimeoutRef.current = setTimeout(() => {
|
||||
setFilesPanelScrollPosition(sessionId, target.scrollTop);
|
||||
}, 150);
|
||||
};
|
||||
viewport.addEventListener("scroll", onScroll);
|
||||
return () => {
|
||||
viewport.removeEventListener("scroll", onScroll);
|
||||
if (scrollSaveTimeoutRef.current) clearTimeout(scrollSaveTimeoutRef.current);
|
||||
};
|
||||
}, [sessionId, tree, scrollAreaRef]);
|
||||
}
|
||||
|
||||
/** Fetch children for a folder node if not already loaded. */
|
||||
export async function loadNodeChildren(
|
||||
node: FileTreeNode,
|
||||
sessionId: string,
|
||||
treeState: ReturnType<typeof useFileBrowserTree>,
|
||||
) {
|
||||
if (node.children && node.children.length > 0) return;
|
||||
// Dedupe in-flight fetches so rapid double-clicks don't issue two WS round-trips.
|
||||
if (treeState.isLoading(node.path)) return;
|
||||
treeState.showLoading(node.path);
|
||||
try {
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
const response = await requestFileTree(client, sessionId, node.path, 1);
|
||||
const updateNode = (n: FileTreeNode): FileTreeNode => {
|
||||
if (n.path === node.path) return { ...n, children: response.root.children };
|
||||
return n.children ? { ...n, children: n.children.map(updateNode) } : n;
|
||||
};
|
||||
if (treeState.tree) treeState.setTree(updateNode(treeState.tree));
|
||||
} catch (error) {
|
||||
console.error("Failed to load children:", error);
|
||||
} finally {
|
||||
treeState.hideLoading(node.path);
|
||||
}
|
||||
}
|
||||
|
||||
export type ToggleFolderExpandDeps = {
|
||||
node: FileTreeNode;
|
||||
sessionId: string;
|
||||
treeState: ReturnType<typeof useFileBrowserTree>;
|
||||
setActiveFolderPath: (path: string) => void;
|
||||
loadChildren?: typeof loadNodeChildren;
|
||||
};
|
||||
|
||||
// Flip expanded synchronously *then* fetch children so the chevron rotates on the first click.
|
||||
export async function toggleFolderExpand({
|
||||
node,
|
||||
sessionId,
|
||||
treeState,
|
||||
setActiveFolderPath,
|
||||
loadChildren = loadNodeChildren,
|
||||
}: ToggleFolderExpandDeps): Promise<void> {
|
||||
if (!node.is_dir) return;
|
||||
setActiveFolderPath(node.path);
|
||||
const wasExpanded = treeState.expandedPaths.has(node.path);
|
||||
treeState.setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(node.path)) next.delete(node.path);
|
||||
else next.add(node.path);
|
||||
return next;
|
||||
});
|
||||
if (!wasExpanded) {
|
||||
await loadChildren(node, sessionId, treeState);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch and open a file by path. */
|
||||
export async function fetchAndOpenFile(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
onOpenFile: (file: OpenFileTab) => void,
|
||||
toast: ReturnType<typeof useToast>["toast"],
|
||||
) {
|
||||
try {
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
const response: FileContentResponse = await requestFileContent(client, sessionId, path);
|
||||
const { calculateHash } = await import("@/lib/utils/file-diff");
|
||||
const hash = await calculateHash(response.content);
|
||||
const name = path.split("/").pop() || path;
|
||||
onOpenFile({
|
||||
path,
|
||||
name,
|
||||
content: response.content,
|
||||
originalContent: response.content,
|
||||
originalHash: hash,
|
||||
isDirty: false,
|
||||
isBinary: response.is_binary,
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : "Unknown error";
|
||||
toast({ title: "Failed to open file", description: reason, variant: "error" });
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,17 @@
|
||||
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import {
|
||||
requestFileTree,
|
||||
requestFileContent,
|
||||
searchWorkspaceFiles,
|
||||
} from "@/lib/ws/workspace-files";
|
||||
import type { FileTreeNode, FileContentResponse, OpenFileTab } from "@/lib/types/backend";
|
||||
import { requestFileTree, searchWorkspaceFiles } from "@/lib/ws/workspace-files";
|
||||
import type { FileTreeNode } from "@/lib/types/backend";
|
||||
import { useSessionAgentctl } from "@/hooks/domains/session/use-session-agentctl";
|
||||
import {
|
||||
getFilesPanelExpandedPaths,
|
||||
setFilesPanelExpandedPaths,
|
||||
getFilesPanelScrollPosition,
|
||||
setFilesPanelScrollPosition,
|
||||
} from "@/lib/local-storage";
|
||||
import type { useToast } from "@/components/toast-provider";
|
||||
import { getFilesPanelExpandedPaths, setFilesPanelExpandedPaths } from "@/lib/local-storage";
|
||||
import { useTree, type VisibleRow } from "@/hooks/use-tree";
|
||||
import { mergeTreeNodes } from "./file-browser-parts";
|
||||
import { compareTreeNodes, sortRootChildren } from "./file-tree-utils";
|
||||
import { createDebugLogger, IS_DEBUG } from "@/lib/debug/log";
|
||||
|
||||
const debugLoad = createDebugLogger("file-browser:load");
|
||||
const debugChanges = createDebugLogger("file-browser:changes");
|
||||
|
||||
const FB_GET_PATH = (n: FileTreeNode) => n.path;
|
||||
// Children are sorted (dirs first, then files, alphabetically) on every
|
||||
@@ -146,7 +140,21 @@ export function applyFileChanges(ctx: {
|
||||
if (parent === "" || expandedPaths.has(parent)) foldersToRefresh.add(parent);
|
||||
if (p === "" || expandedPaths.has(p)) foldersToRefresh.add(p);
|
||||
}
|
||||
if (foldersToRefresh.size === 0) return;
|
||||
if (foldersToRefresh.size === 0) {
|
||||
if (IS_DEBUG)
|
||||
debugChanges("no-folders-to-refresh", {
|
||||
sessionId,
|
||||
candidates: changes.length,
|
||||
expandedPaths: expandedPaths.size,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (IS_DEBUG)
|
||||
debugChanges("refresh", {
|
||||
sessionId,
|
||||
folders: Array.from(foldersToRefresh).slice(0, 5),
|
||||
total: foldersToRefresh.size,
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -237,6 +245,11 @@ type TreeLoaderContext = {
|
||||
setLoadError: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
};
|
||||
|
||||
// Thin wrapper so loadTree callers don't each pay a complexity point for IS_DEBUG.
|
||||
function logLoad(event: string, data: Record<string, unknown>) {
|
||||
if (IS_DEBUG) debugLoad(event, data);
|
||||
}
|
||||
|
||||
function useTreeLoader(ctx: TreeLoaderContext) {
|
||||
const {
|
||||
clearRetryTimer,
|
||||
@@ -254,7 +267,10 @@ function useTreeLoader(ctx: TreeLoaderContext) {
|
||||
const loadInFlightRef = useRef(false);
|
||||
const loadTree = useCallback(
|
||||
async (options?: { resetRetry?: boolean }) => {
|
||||
if (loadInFlightRef.current) return;
|
||||
if (loadInFlightRef.current) {
|
||||
logLoad("skip-in-flight", { sessionId: sessionIdRef.current });
|
||||
return;
|
||||
}
|
||||
loadInFlightRef.current = true;
|
||||
setIsLoadingTree(true);
|
||||
setLoadState("loading");
|
||||
@@ -263,6 +279,11 @@ function useTreeLoader(ctx: TreeLoaderContext) {
|
||||
retryAttemptRef.current = 0;
|
||||
clearRetryTimer();
|
||||
}
|
||||
logLoad("start", {
|
||||
sessionId: sessionIdRef.current,
|
||||
resetRetry: options?.resetRetry === true,
|
||||
retryAttempt: retryAttemptRef.current,
|
||||
});
|
||||
try {
|
||||
const client = getWebSocketClient();
|
||||
if (!client) throw new Error("WebSocket client not available");
|
||||
@@ -271,6 +292,11 @@ function useTreeLoader(ctx: TreeLoaderContext) {
|
||||
setLoadState("loaded");
|
||||
retryAttemptRef.current = 0;
|
||||
clearRetryTimer();
|
||||
logLoad("loaded", {
|
||||
sessionId: sessionIdRef.current,
|
||||
rootPath: response.root?.path ?? null,
|
||||
children: response.root?.children?.length ?? 0,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load file tree";
|
||||
setLoadError(message);
|
||||
@@ -280,11 +306,18 @@ function useTreeLoader(ctx: TreeLoaderContext) {
|
||||
retryAttemptRef.current += 1;
|
||||
setLoadState("waiting");
|
||||
clearRetryTimer();
|
||||
logLoad("retry", {
|
||||
sessionId: sessionIdRef.current,
|
||||
attempt: retryAttemptRef.current,
|
||||
delayMs: delay,
|
||||
error: message,
|
||||
});
|
||||
retryTimerRef.current = setTimeout(() => {
|
||||
void loadTree();
|
||||
}, delay);
|
||||
} else {
|
||||
setLoadState("manual");
|
||||
logLoad("gave-up", { sessionId: sessionIdRef.current, error: message });
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingTree(false);
|
||||
@@ -304,6 +337,91 @@ function useTreeLoader(ctx: TreeLoaderContext) {
|
||||
return loadTree;
|
||||
}
|
||||
|
||||
type TreeLoadEffectsContext = {
|
||||
sessionId: string;
|
||||
effectiveResetKey: string;
|
||||
agentctlIsReady: boolean;
|
||||
agentctlIsReadyRef: React.MutableRefObject<boolean>;
|
||||
loadStateRef: React.MutableRefObject<LoadState>;
|
||||
treeRef: React.MutableRefObject<FileTreeNode | null>;
|
||||
retryAttemptRef: React.MutableRefObject<number>;
|
||||
hasInitializedExpandedRef: React.MutableRefObject<string | null>;
|
||||
clearRetryTimer: () => void;
|
||||
loadTree: (options?: { resetRetry?: boolean }) => Promise<void> | void;
|
||||
setTree: React.Dispatch<React.SetStateAction<FileTreeNode | null>>;
|
||||
setIsLoadingTree: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setLoadState: React.Dispatch<React.SetStateAction<LoadState>>;
|
||||
setLoadError: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setExpandedPaths: (paths: Set<string>) => void;
|
||||
};
|
||||
|
||||
/** Owns the two effects that drive initial load and the waiting→ready flip. */
|
||||
function useTreeLoadEffects(ctx: TreeLoadEffectsContext) {
|
||||
const {
|
||||
sessionId,
|
||||
effectiveResetKey,
|
||||
agentctlIsReady,
|
||||
agentctlIsReadyRef,
|
||||
loadStateRef,
|
||||
treeRef,
|
||||
retryAttemptRef,
|
||||
hasInitializedExpandedRef,
|
||||
clearRetryTimer,
|
||||
loadTree,
|
||||
setTree,
|
||||
setIsLoadingTree,
|
||||
setLoadState,
|
||||
setLoadError,
|
||||
setExpandedPaths,
|
||||
} = ctx;
|
||||
|
||||
useEffect(() => {
|
||||
setTree(null);
|
||||
setIsLoadingTree(true);
|
||||
setLoadState(agentctlIsReadyRef.current ? "loading" : "waiting");
|
||||
setLoadError(null);
|
||||
retryAttemptRef.current = 0;
|
||||
clearRetryTimer();
|
||||
hasInitializedExpandedRef.current = null;
|
||||
const savedPaths = getFilesPanelExpandedPaths(effectiveResetKey);
|
||||
setExpandedPaths(savedPaths.length > 0 ? new Set(savedPaths) : new Set());
|
||||
if (savedPaths.length > 0) hasInitializedExpandedRef.current = effectiveResetKey;
|
||||
logLoad("init-effect", {
|
||||
sessionId,
|
||||
effectiveResetKey,
|
||||
agentctlReady: agentctlIsReadyRef.current,
|
||||
savedPaths: savedPaths.length,
|
||||
willLoad: agentctlIsReadyRef.current,
|
||||
});
|
||||
if (agentctlIsReadyRef.current) void loadTree({ resetRetry: true });
|
||||
else setIsLoadingTree(false);
|
||||
return () => {
|
||||
clearRetryTimer();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- refs intentionally omitted
|
||||
}, [clearRetryTimer, loadTree, effectiveResetKey, setExpandedPaths]);
|
||||
|
||||
// Fire the initial load on the waiting → ready transition. `loadState` is
|
||||
// read via a ref so a failed load (which sets state back to "waiting") does
|
||||
// not re-fire this effect and cancel the retry timer via `resetRetry: true`.
|
||||
// The `treeRef.current` check alongside "loaded" is load-bearing:
|
||||
// `applyFileChanges` can flip state to "loaded" while the tree is still
|
||||
// null, and without the tree check this effect would skip the real load.
|
||||
useEffect(() => {
|
||||
let reason: string | null = null;
|
||||
if (!agentctlIsReady) reason = "agentctl-not-ready";
|
||||
else if (loadStateRef.current === "loading") reason = "already-loading";
|
||||
else if (loadStateRef.current === "loaded" && treeRef.current) reason = "already-loaded";
|
||||
if (reason) {
|
||||
logLoad("ready-effect-skip", { sessionId, reason, loadState: loadStateRef.current });
|
||||
return;
|
||||
}
|
||||
logLoad("ready-flip", { sessionId, loadState: loadStateRef.current });
|
||||
void loadTree({ resetRetry: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- refs intentionally omitted
|
||||
}, [agentctlIsReady, loadTree, sessionId]);
|
||||
}
|
||||
|
||||
function useFileChangeSubscription({
|
||||
sessionIdRef,
|
||||
expandedPathsRef,
|
||||
@@ -323,7 +441,17 @@ function useFileChangeSubscription({
|
||||
if (!client) return;
|
||||
return client.on("session.workspace.file.changes", (msg) => {
|
||||
const changes = msg.payload?.changes;
|
||||
if (!changes || changes.length === 0) return;
|
||||
if (!changes || changes.length === 0) {
|
||||
if (IS_DEBUG) debugChanges("event-empty", { sessionId: sessionIdRef.current });
|
||||
return;
|
||||
}
|
||||
if (IS_DEBUG)
|
||||
debugChanges("event", {
|
||||
sessionId: sessionIdRef.current,
|
||||
count: changes.length,
|
||||
expandedPaths: expandedPathsRef.current.size,
|
||||
firstPaths: changes.slice(0, 3).map((c: { path: string }) => c.path),
|
||||
});
|
||||
applyFileChanges({
|
||||
client,
|
||||
sessionId: sessionIdRef.current,
|
||||
@@ -400,37 +528,23 @@ export function useFileBrowserTree(sessionId: string, resetKey?: string) {
|
||||
agentctlIsReadyRef.current = agentctlStatus.isReady;
|
||||
loadStateRef.current = loadState;
|
||||
treeRef.current = tree;
|
||||
useEffect(() => {
|
||||
setTree(null);
|
||||
setIsLoadingTree(true);
|
||||
setLoadState(agentctlIsReadyRef.current ? "loading" : "waiting");
|
||||
setLoadError(null);
|
||||
retryAttemptRef.current = 0;
|
||||
clearRetryTimer();
|
||||
hasInitializedExpandedRef.current = null;
|
||||
const savedPaths = getFilesPanelExpandedPaths(effectiveResetKey);
|
||||
setExpandedPaths(savedPaths.length > 0 ? new Set(savedPaths) : new Set());
|
||||
if (savedPaths.length > 0) hasInitializedExpandedRef.current = effectiveResetKey;
|
||||
// Only load now if agentctl is ready; otherwise the sibling effect fires
|
||||
// on the ready flip. Prevents burning the retry budget during slow prepare.
|
||||
if (agentctlIsReadyRef.current) void loadTree({ resetRetry: true });
|
||||
else setIsLoadingTree(false);
|
||||
return () => {
|
||||
clearRetryTimer();
|
||||
};
|
||||
}, [clearRetryTimer, loadTree, effectiveResetKey, sessionId, setExpandedPaths]);
|
||||
|
||||
// Fire the initial load on the waiting → ready transition. `loadState` is
|
||||
// read via a ref so a failed load (which sets state back to "waiting") does
|
||||
// not re-fire this effect and cancel the retry timer via `resetRetry: true`.
|
||||
// "loaded" only counts when a tree is actually present — `applyFileChanges`
|
||||
// can flip state to "loaded" while the tree is still null.
|
||||
useEffect(() => {
|
||||
if (!agentctlStatus.isReady) return;
|
||||
if (loadStateRef.current === "loading") return;
|
||||
if (loadStateRef.current === "loaded" && treeRef.current) return;
|
||||
void loadTree({ resetRetry: true });
|
||||
}, [agentctlStatus.isReady, loadTree, sessionId]);
|
||||
useTreeLoadEffects({
|
||||
sessionId,
|
||||
effectiveResetKey,
|
||||
agentctlIsReady: agentctlStatus.isReady,
|
||||
agentctlIsReadyRef,
|
||||
loadStateRef,
|
||||
treeRef,
|
||||
retryAttemptRef,
|
||||
hasInitializedExpandedRef,
|
||||
clearRetryTimer,
|
||||
loadTree,
|
||||
setTree,
|
||||
setIsLoadingTree,
|
||||
setLoadState,
|
||||
setLoadError,
|
||||
setExpandedPaths,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!tree || isLoadingTree || hasInitializedExpandedRef.current === effectiveResetKey) return;
|
||||
@@ -467,133 +581,13 @@ export function useFileBrowserTree(sessionId: string, resetKey?: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Hook for scroll position persistence in the file browser. */
|
||||
export function useScrollPersistence(
|
||||
sessionId: string,
|
||||
isTreeLoaded: boolean,
|
||||
scrollAreaRef: React.RefObject<HTMLDivElement | null>,
|
||||
tree: FileTreeNode | null,
|
||||
) {
|
||||
const scrollSaveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasRestoredScrollRef = useRef<string | null>(null);
|
||||
|
||||
// Restore scroll position after tree loads
|
||||
useEffect(() => {
|
||||
if (!isTreeLoaded || hasRestoredScrollRef.current === sessionId) return;
|
||||
const savedScroll = getFilesPanelScrollPosition(sessionId);
|
||||
if (savedScroll > 0 && scrollAreaRef.current) {
|
||||
const viewport = scrollAreaRef.current.querySelector("[data-radix-scroll-area-viewport]");
|
||||
if (viewport) {
|
||||
viewport.scrollTop = savedScroll;
|
||||
hasRestoredScrollRef.current = sessionId;
|
||||
}
|
||||
} else {
|
||||
hasRestoredScrollRef.current = sessionId;
|
||||
}
|
||||
}, [isTreeLoaded, sessionId, scrollAreaRef]);
|
||||
|
||||
// Attach scroll listener to ScrollArea viewport
|
||||
useEffect(() => {
|
||||
const el = scrollAreaRef.current;
|
||||
if (!el) return;
|
||||
const viewport = el.querySelector("[data-radix-scroll-area-viewport]");
|
||||
if (!viewport) return;
|
||||
const onScroll = (event: Event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (scrollSaveTimeoutRef.current) clearTimeout(scrollSaveTimeoutRef.current);
|
||||
scrollSaveTimeoutRef.current = setTimeout(() => {
|
||||
setFilesPanelScrollPosition(sessionId, target.scrollTop);
|
||||
}, 150);
|
||||
};
|
||||
viewport.addEventListener("scroll", onScroll);
|
||||
return () => {
|
||||
viewport.removeEventListener("scroll", onScroll);
|
||||
if (scrollSaveTimeoutRef.current) clearTimeout(scrollSaveTimeoutRef.current);
|
||||
};
|
||||
}, [sessionId, tree, scrollAreaRef]);
|
||||
}
|
||||
|
||||
/** Fetch children for a folder node if not already loaded. */
|
||||
export async function loadNodeChildren(
|
||||
node: FileTreeNode,
|
||||
sessionId: string,
|
||||
treeState: ReturnType<typeof useFileBrowserTree>,
|
||||
) {
|
||||
if (node.children && node.children.length > 0) return;
|
||||
// Dedupe in-flight fetches so rapid double-clicks don't issue two WS round-trips.
|
||||
if (treeState.isLoading(node.path)) return;
|
||||
treeState.showLoading(node.path);
|
||||
try {
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
const response = await requestFileTree(client, sessionId, node.path, 1);
|
||||
const updateNode = (n: FileTreeNode): FileTreeNode => {
|
||||
if (n.path === node.path) return { ...n, children: response.root.children };
|
||||
return n.children ? { ...n, children: n.children.map(updateNode) } : n;
|
||||
};
|
||||
if (treeState.tree) treeState.setTree(updateNode(treeState.tree));
|
||||
} catch (error) {
|
||||
console.error("Failed to load children:", error);
|
||||
} finally {
|
||||
treeState.hideLoading(node.path);
|
||||
}
|
||||
}
|
||||
|
||||
export type ToggleFolderExpandDeps = {
|
||||
node: FileTreeNode;
|
||||
sessionId: string;
|
||||
treeState: ReturnType<typeof useFileBrowserTree>;
|
||||
setActiveFolderPath: (path: string) => void;
|
||||
loadChildren?: typeof loadNodeChildren;
|
||||
};
|
||||
|
||||
// Flip expanded synchronously *then* fetch children so the chevron rotates on the first click.
|
||||
export async function toggleFolderExpand({
|
||||
node,
|
||||
sessionId,
|
||||
treeState,
|
||||
setActiveFolderPath,
|
||||
loadChildren = loadNodeChildren,
|
||||
}: ToggleFolderExpandDeps): Promise<void> {
|
||||
if (!node.is_dir) return;
|
||||
setActiveFolderPath(node.path);
|
||||
const wasExpanded = treeState.expandedPaths.has(node.path);
|
||||
treeState.setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(node.path)) next.delete(node.path);
|
||||
else next.add(node.path);
|
||||
return next;
|
||||
});
|
||||
if (!wasExpanded) {
|
||||
await loadChildren(node, sessionId, treeState);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch and open a file by path. */
|
||||
export async function fetchAndOpenFile(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
onOpenFile: (file: OpenFileTab) => void,
|
||||
toast: ReturnType<typeof useToast>["toast"],
|
||||
) {
|
||||
try {
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
const response: FileContentResponse = await requestFileContent(client, sessionId, path);
|
||||
const { calculateHash } = await import("@/lib/utils/file-diff");
|
||||
const hash = await calculateHash(response.content);
|
||||
const name = path.split("/").pop() || path;
|
||||
onOpenFile({
|
||||
path,
|
||||
name,
|
||||
content: response.content,
|
||||
originalContent: response.content,
|
||||
originalHash: hash,
|
||||
isDirty: false,
|
||||
isBinary: response.is_binary,
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : "Unknown error";
|
||||
toast({ title: "Failed to open file", description: reason, variant: "error" });
|
||||
}
|
||||
}
|
||||
// useScrollPersistence, loadNodeChildren, toggleFolderExpand, fetchAndOpenFile,
|
||||
// and the ToggleFolderExpandDeps type live in ./file-browser-actions.ts to keep
|
||||
// this file within the 600-line lint limit.
|
||||
export {
|
||||
useScrollPersistence,
|
||||
loadNodeChildren,
|
||||
toggleFolderExpand,
|
||||
fetchAndOpenFile,
|
||||
} from "./file-browser-actions";
|
||||
export type { ToggleFolderExpandDeps } from "./file-browser-actions";
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import { createDebugLogger } from "@/lib/debug/log";
|
||||
|
||||
const debug = createDebugLogger("agentctl:status");
|
||||
|
||||
export function useSessionAgentctl(sessionId: string | null) {
|
||||
const session = useAppStore((state) =>
|
||||
@@ -19,14 +22,30 @@ export function useSessionAgentctl(sessionId: string | null) {
|
||||
return client.subscribeSession(session.id);
|
||||
}, [session?.id, connectionStatus]);
|
||||
|
||||
const isReady = status?.status === "ready";
|
||||
// Log status transitions only — re-rendering should not spam.
|
||||
const lastLoggedRef = useRef<string | null>(null);
|
||||
const statusValue = status?.status ?? "missing";
|
||||
const snapshot = `${sessionId ?? "none"}|${statusValue}|${status?.errorMessage ?? ""}|${status?.agentExecutionId ?? ""}`;
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
if (lastLoggedRef.current === snapshot) return;
|
||||
debug("transition", {
|
||||
sessionId,
|
||||
from: lastLoggedRef.current ?? "init",
|
||||
status: statusValue,
|
||||
errorMessage: status?.errorMessage ?? null,
|
||||
agentExecutionId: status?.agentExecutionId ?? null,
|
||||
connectionStatus,
|
||||
});
|
||||
lastLoggedRef.current = snapshot;
|
||||
}, [sessionId, snapshot, statusValue, status, connectionStatus]);
|
||||
|
||||
return {
|
||||
status: status?.status ?? "starting",
|
||||
errorMessage: status?.errorMessage,
|
||||
agentExecutionId: status?.agentExecutionId,
|
||||
isReady,
|
||||
isStarting: status?.status === "starting" || !status,
|
||||
isError: status?.status === "error",
|
||||
isReady: statusValue === "ready",
|
||||
isStarting: statusValue === "starting" || !status,
|
||||
isError: statusValue === "error",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ import { useEffect, useMemo } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import { createDebugLogger } from "@/lib/debug/log";
|
||||
import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types";
|
||||
|
||||
const debugSub = createDebugLogger("git-status:subscribe");
|
||||
|
||||
/**
|
||||
* Hook to get the current git status for a session.
|
||||
* Git status is keyed by environment ID so sessions sharing an environment share git state.
|
||||
@@ -24,19 +27,29 @@ export function useSessionGitStatus(sessionId: string | null) {
|
||||
// Subscribe to session updates to receive git status via WebSocket
|
||||
// The workspace stream sends current git status immediately on subscription
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
if (!sessionId) {
|
||||
debugSub("skip", { reason: "no-session-id", connectionStatus });
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for WebSocket to be connected before subscribing
|
||||
if (connectionStatus !== "connected") return;
|
||||
if (connectionStatus !== "connected") {
|
||||
debugSub("skip", { sessionId, reason: "not-connected", connectionStatus });
|
||||
return;
|
||||
}
|
||||
|
||||
const client = getWebSocketClient();
|
||||
if (client) {
|
||||
const unsubscribe = client.subscribeSession(sessionId);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
// Don't clear git status on cleanup - keep it cached for when user switches back
|
||||
};
|
||||
if (!client) {
|
||||
debugSub("skip", { sessionId, reason: "no-client", connectionStatus });
|
||||
return;
|
||||
}
|
||||
debugSub("subscribe", { sessionId, connectionStatus });
|
||||
const unsubscribe = client.subscribeSession(sessionId);
|
||||
return () => {
|
||||
debugSub("unsubscribe", { sessionId });
|
||||
unsubscribe();
|
||||
// Don't clear git status on cleanup - keep it cached for when user switches back
|
||||
};
|
||||
}, [sessionId, connectionStatus]);
|
||||
|
||||
return gitStatus;
|
||||
|
||||
@@ -74,3 +74,43 @@ describe("createDebugLogger", () => {
|
||||
expect(console.debug).toHaveBeenCalledWith("[ns] prefix a=1 suffix");
|
||||
});
|
||||
});
|
||||
|
||||
describe("IS_DEBUG", () => {
|
||||
// Module re-imports are required because IS_DEBUG is a module-level constant
|
||||
// evaluated at import time. resetModules forces re-evaluation with the
|
||||
// current env / globals each time.
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("is true in a production build when window.__KANDEV_DEBUG is set", async () => {
|
||||
// Simulates `make start-debug`: production bundle, runtime window flag.
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("NEXT_PUBLIC_KANDEV_DEBUG", "");
|
||||
vi.stubGlobal("window", { __KANDEV_DEBUG: true });
|
||||
const { IS_DEBUG } = await import("./log");
|
||||
expect(IS_DEBUG).toBe(true);
|
||||
});
|
||||
|
||||
it("is false in a production build with no flag set", async () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("NEXT_PUBLIC_KANDEV_DEBUG", "");
|
||||
vi.stubGlobal("window", {});
|
||||
const { IS_DEBUG } = await import("./log");
|
||||
expect(IS_DEBUG).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when NEXT_PUBLIC_KANDEV_DEBUG=true at build time", async () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("NEXT_PUBLIC_KANDEV_DEBUG", "true");
|
||||
vi.stubGlobal("window", {});
|
||||
const { IS_DEBUG } = await import("./log");
|
||||
expect(IS_DEBUG).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
/**
|
||||
* Namespaced debug logger for development.
|
||||
*
|
||||
* Active when `NODE_ENV !== "production"` (i.e. `make dev`) **or** when
|
||||
* `NEXT_PUBLIC_KANDEV_DEBUG=true` is set at build time (i.e. `make
|
||||
* start-debug`). In a plain production build both conditions are false and
|
||||
* Next.js dead-code-eliminates everything.
|
||||
* Active when any of the following is true:
|
||||
* - `NODE_ENV !== "production"` (i.e. `make dev`)
|
||||
* - `NEXT_PUBLIC_KANDEV_DEBUG=true` at build time (inlined into the bundle)
|
||||
* - `window.__KANDEV_DEBUG === true` at runtime (set by `layout.tsx` when
|
||||
* the server-side env var is present, e.g. `make start-debug`)
|
||||
*
|
||||
* The runtime `window` check exists because `make start-debug` re-uses the
|
||||
* already-built production web bundle and only flips the env var on the
|
||||
* server process. Without the runtime fallback, the inlined `process.env`
|
||||
* value stays `false` in the client bundle and no logs surface.
|
||||
*
|
||||
* The `debug()` call itself is free, but JavaScript evaluates its arguments
|
||||
* before the call — so callers that compute expensive values (O(n) maps,
|
||||
@@ -12,16 +18,46 @@
|
||||
*
|
||||
* if (IS_DEBUG) { debug(...); }
|
||||
*
|
||||
* Next.js inlines both `process.env` checks at build time and Terser folds
|
||||
* `IS_DEBUG` to a boolean literal, dead-code-eliminating the block in regular
|
||||
* production builds. Simple scalar reads are cheap enough to leave unguarded.
|
||||
* In a production build with no flag set, both `process.env` checks fold to
|
||||
* `false` and the `window` check short-circuits at runtime, so the guarded
|
||||
* block is effectively a no-op.
|
||||
*
|
||||
* Output format is logfmt-ish so logs are flat and grep/copy-friendly:
|
||||
*
|
||||
* [namespace] message key1=value key2="value with space" key3={"nested":1}
|
||||
*
|
||||
* ## Namespace convention
|
||||
*
|
||||
* Names use `<domain>:<aspect>` so a devtools console filter can narrow on
|
||||
* either part. Known namespaces in this codebase (keep this list current
|
||||
* when adding new loggers — it's the cheat-sheet for triage):
|
||||
*
|
||||
* Git / Changes panel pipeline (bug: stale until refresh)
|
||||
* [git-status:subscribe] useSessionGitStatus subscribe/unsubscribe cycle
|
||||
* [ws:dispatch] every WS message — inbound notifications and
|
||||
* outbound sends (`message="send"`). Streaming
|
||||
* chunk traffic is denylisted.
|
||||
* [git-status:ws] git event handler — status_update / commit_created / ...
|
||||
* [git-status:store] setGitStatus — overwrite decision + prev/next counts
|
||||
* [git-status:derive] useSessionGit file aggregation across repos
|
||||
*
|
||||
* Files panel pipeline (bug: stuck loading skeleton)
|
||||
* [agentctl:status] per-session agentctl status transitions
|
||||
* [file-browser:load] tree loader — init / ready-flip / start / retry / gave-up
|
||||
* [file-browser:changes] session.workspace.file.changes events + folder refresh
|
||||
*
|
||||
* Other
|
||||
* [ws:connection] WS hook mount + status transitions
|
||||
* [dockview:*] layout restore / save / env-switch / session-tabs
|
||||
* [messages:*] message fetch / process / lazyload
|
||||
* [session:env-mapping] session → environment ID mapping
|
||||
*
|
||||
* Tip: in Chrome devtools the console filter input takes substrings and regex.
|
||||
* Use `[git-status:` for the whole git pipeline, `[ws:dispatch] action=session.git`
|
||||
* to scope WS dispatch to git, etc.
|
||||
*
|
||||
* Usage:
|
||||
* const debug = createDebugLogger("git-status");
|
||||
* const debug = createDebugLogger("git-status:ws");
|
||||
* debug("status_update received", { sessionId, fileCount });
|
||||
*
|
||||
* Logs go through `console.debug`, which the log interceptor mirrors into the
|
||||
@@ -32,7 +68,9 @@
|
||||
export type DebugLogger = (...args: unknown[]) => void;
|
||||
|
||||
export const IS_DEBUG =
|
||||
process.env.NODE_ENV !== "production" || process.env.NEXT_PUBLIC_KANDEV_DEBUG === "true";
|
||||
process.env.NODE_ENV !== "production" ||
|
||||
process.env.NEXT_PUBLIC_KANDEV_DEBUG === "true" ||
|
||||
(typeof window !== "undefined" && window.__KANDEV_DEBUG === true);
|
||||
|
||||
const NOOP: DebugLogger = () => {};
|
||||
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import type { BackendMessageMap, BackendMessageType } from "@/lib/types/backend";
|
||||
import type { ConnectionStatus } from "@/lib/types/connection";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { createDebugLogger, IS_DEBUG } from "@/lib/debug/log";
|
||||
|
||||
const debugDispatch = createDebugLogger("ws:dispatch");
|
||||
|
||||
// High-frequency notification types we skip in the dispatch log to avoid
|
||||
// drowning the console during agent streams. Filter [ws:dispatch] to see
|
||||
// everything else; if you need the streaming traffic, comment this out.
|
||||
const DISPATCH_LOG_DENYLIST = new Set<string>([
|
||||
"session.message.added",
|
||||
"session.message.updated",
|
||||
"session.shell.output",
|
||||
"session.process.output",
|
||||
]);
|
||||
|
||||
type MessageHandler<T extends BackendMessageType> = (message: BackendMessageMap[T]) => void;
|
||||
|
||||
@@ -115,6 +128,20 @@ export class WebSocketClient {
|
||||
|
||||
send(payload: unknown) {
|
||||
const data = JSON.stringify(payload);
|
||||
if (IS_DEBUG) {
|
||||
const p = payload as { action?: string; id?: string; type?: string } | null;
|
||||
const action = p?.action ?? "?";
|
||||
if (!DISPATCH_LOG_DENYLIST.has(action)) {
|
||||
const sessionId = (p as { payload?: { session_id?: string } } | null)?.payload?.session_id;
|
||||
debugDispatch("send", {
|
||||
action,
|
||||
id: p?.id ?? null,
|
||||
type: p?.type ?? null,
|
||||
sessionId: sessionId ?? null,
|
||||
queued: this.status !== "connected" || !this.socket,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (this.status !== "connected" || !this.socket) {
|
||||
this.pendingQueue.push(data);
|
||||
return;
|
||||
@@ -324,14 +351,26 @@ export class WebSocketClient {
|
||||
}
|
||||
}
|
||||
|
||||
private debugNotification(action: BackendMessageType, payload: unknown, handlerCount: number) {
|
||||
if (!IS_DEBUG || DISPATCH_LOG_DENYLIST.has(action)) return;
|
||||
const payloadSessionId = (payload as { session_id?: string } | undefined)?.session_id;
|
||||
debugDispatch("notification", {
|
||||
action,
|
||||
sessionId: payloadSessionId ?? null,
|
||||
handlers: handlerCount,
|
||||
});
|
||||
}
|
||||
|
||||
private handleParsedMessage(message: BackendMessageMap[BackendMessageType]) {
|
||||
const msgWithId = message as { id?: string; type: string };
|
||||
|
||||
if (msgWithId.type === "response" && msgWithId.id) {
|
||||
if (IS_DEBUG) debugDispatch("response", { id: msgWithId.id });
|
||||
this.resolvePendingRequest(msgWithId.id, message.payload);
|
||||
return;
|
||||
}
|
||||
if (msgWithId.type === "error" && msgWithId.id) {
|
||||
if (IS_DEBUG) debugDispatch("error-response", { id: msgWithId.id });
|
||||
this.rejectPendingRequest(msgWithId.id, message.payload);
|
||||
return;
|
||||
}
|
||||
@@ -340,6 +379,7 @@ export class WebSocketClient {
|
||||
const action = (message as { action?: string })?.action as BackendMessageType | undefined;
|
||||
if (!action) return;
|
||||
const handlers = this.handlers.get(action);
|
||||
this.debugNotification(action, message.payload, handlers?.size ?? 0);
|
||||
if (handlers) {
|
||||
handlers.forEach((handler) => handler(message));
|
||||
}
|
||||
|
||||
@@ -67,6 +67,14 @@ const gitEventHandlers: GitEventHandlers = {
|
||||
},
|
||||
|
||||
commit_created: (store, event) => {
|
||||
if (IS_DEBUG) {
|
||||
debug("commit_created", {
|
||||
sessionId: event.session_id,
|
||||
sha: event.commit.commit_sha,
|
||||
repositoryName: event.commit.repository_name ?? null,
|
||||
filesChanged: event.commit.files_changed,
|
||||
});
|
||||
}
|
||||
store.getState().addSessionCommit(event.session_id, {
|
||||
id: event.commit.id,
|
||||
session_id: event.session_id,
|
||||
@@ -88,6 +96,7 @@ const gitEventHandlers: GitEventHandlers = {
|
||||
},
|
||||
|
||||
commits_reset: (store, event) => {
|
||||
if (IS_DEBUG) debug("commits_reset", { sessionId: event.session_id });
|
||||
// Clear commits to trigger refetch in useSessionCommits hook
|
||||
store.getState().clearSessionCommits(event.session_id);
|
||||
// Invalidate cumulative diff cache when commits are reset
|
||||
@@ -95,6 +104,7 @@ const gitEventHandlers: GitEventHandlers = {
|
||||
},
|
||||
|
||||
branch_switched: (store, event) => {
|
||||
if (IS_DEBUG) debug("branch_switched", { sessionId: event.session_id });
|
||||
// Clear commits to trigger refetch with new base commit
|
||||
store.getState().clearSessionCommits(event.session_id);
|
||||
// Invalidate cumulative diff cache when branch switches
|
||||
|
||||
@@ -22,20 +22,6 @@ export function registerSessionModelsHandlers(store: StoreApi<AppState>): WsHand
|
||||
currentModelId = modelOpt.current_value;
|
||||
}
|
||||
}
|
||||
console.log("[session-models] received session.models_updated", {
|
||||
sessionId: payload.session_id,
|
||||
rawCurrentModelId: payload.current_model_id,
|
||||
resolvedCurrentModelId: currentModelId,
|
||||
modelsCount: acpModels.length,
|
||||
models: acpModels.map((m) => ({ id: m.model_id, name: m.name })),
|
||||
configOptions: (payload.config_options ?? []).map((o) => ({
|
||||
id: o.id,
|
||||
category: o.category,
|
||||
currentValue: o.current_value,
|
||||
options: o.options?.map((opt: { value: string; name: string }) => opt.value),
|
||||
})),
|
||||
rawPayload: payload,
|
||||
});
|
||||
store.getState().setSessionModels(payload.session_id, {
|
||||
currentModelId,
|
||||
models: acpModels.map((m) => ({
|
||||
@@ -61,10 +47,6 @@ export function registerSessionModelsHandlers(store: StoreApi<AppState>): WsHand
|
||||
const state = store.getState();
|
||||
const currentActive = state.activeModel.bySessionId[payload.session_id];
|
||||
if (currentActive && !acpModels.some((m) => m.model_id === currentActive)) {
|
||||
console.log("[session-models] clearing stale activeModel", {
|
||||
sessionId: payload.session_id,
|
||||
staleModel: currentActive,
|
||||
});
|
||||
state.setActiveModel(payload.session_id, "");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user