fix(web): honor window.__KANDEV_DEBUG so make start-debug logs surface (#991)

This commit is contained in:
Carlos Florêncio
2026-05-22 13:46:11 +01:00
committed by GitHub
parent e0a4a38796
commit e85c340797
9 changed files with 493 additions and 217 deletions
@@ -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" });
}
}
+171 -177
View File
@@ -2,23 +2,17 @@
import { useEffect, useState, useCallback, useRef, useMemo } from "react"; import { useEffect, useState, useCallback, useRef, useMemo } from "react";
import { getWebSocketClient } from "@/lib/ws/connection"; import { getWebSocketClient } from "@/lib/ws/connection";
import { import { requestFileTree, searchWorkspaceFiles } from "@/lib/ws/workspace-files";
requestFileTree, import type { FileTreeNode } from "@/lib/types/backend";
requestFileContent,
searchWorkspaceFiles,
} from "@/lib/ws/workspace-files";
import type { FileTreeNode, FileContentResponse, OpenFileTab } from "@/lib/types/backend";
import { useSessionAgentctl } from "@/hooks/domains/session/use-session-agentctl"; import { useSessionAgentctl } from "@/hooks/domains/session/use-session-agentctl";
import { import { getFilesPanelExpandedPaths, setFilesPanelExpandedPaths } from "@/lib/local-storage";
getFilesPanelExpandedPaths,
setFilesPanelExpandedPaths,
getFilesPanelScrollPosition,
setFilesPanelScrollPosition,
} from "@/lib/local-storage";
import type { useToast } from "@/components/toast-provider";
import { useTree, type VisibleRow } from "@/hooks/use-tree"; import { useTree, type VisibleRow } from "@/hooks/use-tree";
import { mergeTreeNodes } from "./file-browser-parts"; import { mergeTreeNodes } from "./file-browser-parts";
import { compareTreeNodes, sortRootChildren } from "./file-tree-utils"; 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; const FB_GET_PATH = (n: FileTreeNode) => n.path;
// Children are sorted (dirs first, then files, alphabetically) on every // 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 (parent === "" || expandedPaths.has(parent)) foldersToRefresh.add(parent);
if (p === "" || expandedPaths.has(p)) foldersToRefresh.add(p); 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 () => { void (async () => {
try { try {
@@ -237,6 +245,11 @@ type TreeLoaderContext = {
setLoadError: React.Dispatch<React.SetStateAction<string | null>>; 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) { function useTreeLoader(ctx: TreeLoaderContext) {
const { const {
clearRetryTimer, clearRetryTimer,
@@ -254,7 +267,10 @@ function useTreeLoader(ctx: TreeLoaderContext) {
const loadInFlightRef = useRef(false); const loadInFlightRef = useRef(false);
const loadTree = useCallback( const loadTree = useCallback(
async (options?: { resetRetry?: boolean }) => { async (options?: { resetRetry?: boolean }) => {
if (loadInFlightRef.current) return; if (loadInFlightRef.current) {
logLoad("skip-in-flight", { sessionId: sessionIdRef.current });
return;
}
loadInFlightRef.current = true; loadInFlightRef.current = true;
setIsLoadingTree(true); setIsLoadingTree(true);
setLoadState("loading"); setLoadState("loading");
@@ -263,6 +279,11 @@ function useTreeLoader(ctx: TreeLoaderContext) {
retryAttemptRef.current = 0; retryAttemptRef.current = 0;
clearRetryTimer(); clearRetryTimer();
} }
logLoad("start", {
sessionId: sessionIdRef.current,
resetRetry: options?.resetRetry === true,
retryAttempt: retryAttemptRef.current,
});
try { try {
const client = getWebSocketClient(); const client = getWebSocketClient();
if (!client) throw new Error("WebSocket client not available"); if (!client) throw new Error("WebSocket client not available");
@@ -271,6 +292,11 @@ function useTreeLoader(ctx: TreeLoaderContext) {
setLoadState("loaded"); setLoadState("loaded");
retryAttemptRef.current = 0; retryAttemptRef.current = 0;
clearRetryTimer(); clearRetryTimer();
logLoad("loaded", {
sessionId: sessionIdRef.current,
rootPath: response.root?.path ?? null,
children: response.root?.children?.length ?? 0,
});
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to load file tree"; const message = error instanceof Error ? error.message : "Failed to load file tree";
setLoadError(message); setLoadError(message);
@@ -280,11 +306,18 @@ function useTreeLoader(ctx: TreeLoaderContext) {
retryAttemptRef.current += 1; retryAttemptRef.current += 1;
setLoadState("waiting"); setLoadState("waiting");
clearRetryTimer(); clearRetryTimer();
logLoad("retry", {
sessionId: sessionIdRef.current,
attempt: retryAttemptRef.current,
delayMs: delay,
error: message,
});
retryTimerRef.current = setTimeout(() => { retryTimerRef.current = setTimeout(() => {
void loadTree(); void loadTree();
}, delay); }, delay);
} else { } else {
setLoadState("manual"); setLoadState("manual");
logLoad("gave-up", { sessionId: sessionIdRef.current, error: message });
} }
} finally { } finally {
setIsLoadingTree(false); setIsLoadingTree(false);
@@ -304,6 +337,91 @@ function useTreeLoader(ctx: TreeLoaderContext) {
return loadTree; 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({ function useFileChangeSubscription({
sessionIdRef, sessionIdRef,
expandedPathsRef, expandedPathsRef,
@@ -323,7 +441,17 @@ function useFileChangeSubscription({
if (!client) return; if (!client) return;
return client.on("session.workspace.file.changes", (msg) => { return client.on("session.workspace.file.changes", (msg) => {
const changes = msg.payload?.changes; 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({ applyFileChanges({
client, client,
sessionId: sessionIdRef.current, sessionId: sessionIdRef.current,
@@ -400,37 +528,23 @@ export function useFileBrowserTree(sessionId: string, resetKey?: string) {
agentctlIsReadyRef.current = agentctlStatus.isReady; agentctlIsReadyRef.current = agentctlStatus.isReady;
loadStateRef.current = loadState; loadStateRef.current = loadState;
treeRef.current = tree; treeRef.current = tree;
useEffect(() => { useTreeLoadEffects({
setTree(null); sessionId,
setIsLoadingTree(true); effectiveResetKey,
setLoadState(agentctlIsReadyRef.current ? "loading" : "waiting"); agentctlIsReady: agentctlStatus.isReady,
setLoadError(null); agentctlIsReadyRef,
retryAttemptRef.current = 0; loadStateRef,
clearRetryTimer(); treeRef,
hasInitializedExpandedRef.current = null; retryAttemptRef,
const savedPaths = getFilesPanelExpandedPaths(effectiveResetKey); hasInitializedExpandedRef,
setExpandedPaths(savedPaths.length > 0 ? new Set(savedPaths) : new Set()); clearRetryTimer,
if (savedPaths.length > 0) hasInitializedExpandedRef.current = effectiveResetKey; loadTree,
// Only load now if agentctl is ready; otherwise the sibling effect fires setTree,
// on the ready flip. Prevents burning the retry budget during slow prepare. setIsLoadingTree,
if (agentctlIsReadyRef.current) void loadTree({ resetRetry: true }); setLoadState,
else setIsLoadingTree(false); setLoadError,
return () => { setExpandedPaths,
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]);
useEffect(() => { useEffect(() => {
if (!tree || isLoadingTree || hasInitializedExpandedRef.current === effectiveResetKey) return; 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. */ // useScrollPersistence, loadNodeChildren, toggleFolderExpand, fetchAndOpenFile,
export function useScrollPersistence( // and the ToggleFolderExpandDeps type live in ./file-browser-actions.ts to keep
sessionId: string, // this file within the 600-line lint limit.
isTreeLoaded: boolean, export {
scrollAreaRef: React.RefObject<HTMLDivElement | null>, useScrollPersistence,
tree: FileTreeNode | null, loadNodeChildren,
) { toggleFolderExpand,
const scrollSaveTimeoutRef = useRef<NodeJS.Timeout | null>(null); fetchAndOpenFile,
const hasRestoredScrollRef = useRef<string | null>(null); } from "./file-browser-actions";
export type { ToggleFolderExpandDeps } from "./file-browser-actions";
// 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" });
}
}
@@ -1,6 +1,9 @@
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { useAppStore } from "@/components/state-provider"; import { useAppStore } from "@/components/state-provider";
import { getWebSocketClient } from "@/lib/ws/connection"; import { getWebSocketClient } from "@/lib/ws/connection";
import { createDebugLogger } from "@/lib/debug/log";
const debug = createDebugLogger("agentctl:status");
export function useSessionAgentctl(sessionId: string | null) { export function useSessionAgentctl(sessionId: string | null) {
const session = useAppStore((state) => const session = useAppStore((state) =>
@@ -19,14 +22,30 @@ export function useSessionAgentctl(sessionId: string | null) {
return client.subscribeSession(session.id); return client.subscribeSession(session.id);
}, [session?.id, connectionStatus]); }, [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 { return {
status: status?.status ?? "starting", status: status?.status ?? "starting",
errorMessage: status?.errorMessage, errorMessage: status?.errorMessage,
agentExecutionId: status?.agentExecutionId, agentExecutionId: status?.agentExecutionId,
isReady, isReady: statusValue === "ready",
isStarting: status?.status === "starting" || !status, isStarting: statusValue === "starting" || !status,
isError: status?.status === "error", isError: statusValue === "error",
}; };
} }
@@ -2,8 +2,11 @@ import { useEffect, useMemo } from "react";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useAppStore } from "@/components/state-provider"; import { useAppStore } from "@/components/state-provider";
import { getWebSocketClient } from "@/lib/ws/connection"; import { getWebSocketClient } from "@/lib/ws/connection";
import { createDebugLogger } from "@/lib/debug/log";
import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types"; 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. * 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. * 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 // Subscribe to session updates to receive git status via WebSocket
// The workspace stream sends current git status immediately on subscription // The workspace stream sends current git status immediately on subscription
useEffect(() => { useEffect(() => {
if (!sessionId) return; if (!sessionId) {
debugSub("skip", { reason: "no-session-id", connectionStatus });
return;
}
// Wait for WebSocket to be connected before subscribing // 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(); const client = getWebSocketClient();
if (client) { if (!client) {
const unsubscribe = client.subscribeSession(sessionId); debugSub("skip", { sessionId, reason: "no-client", connectionStatus });
return () => { return;
unsubscribe();
// Don't clear git status on cleanup - keep it cached for when user switches back
};
} }
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]); }, [sessionId, connectionStatus]);
return gitStatus; return gitStatus;
+40
View File
@@ -74,3 +74,43 @@ describe("createDebugLogger", () => {
expect(console.debug).toHaveBeenCalledWith("[ns] prefix a=1 suffix"); 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);
});
});
+47 -9
View File
@@ -1,10 +1,16 @@
/** /**
* Namespaced debug logger for development. * Namespaced debug logger for development.
* *
* Active when `NODE_ENV !== "production"` (i.e. `make dev`) **or** when * Active when any of the following is true:
* `NEXT_PUBLIC_KANDEV_DEBUG=true` is set at build time (i.e. `make * - `NODE_ENV !== "production"` (i.e. `make dev`)
* start-debug`). In a plain production build both conditions are false and * - `NEXT_PUBLIC_KANDEV_DEBUG=true` at build time (inlined into the bundle)
* Next.js dead-code-eliminates everything. * - `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 * The `debug()` call itself is free, but JavaScript evaluates its arguments
* before the call — so callers that compute expensive values (O(n) maps, * before the call — so callers that compute expensive values (O(n) maps,
@@ -12,16 +18,46 @@
* *
* if (IS_DEBUG) { debug(...); } * if (IS_DEBUG) { debug(...); }
* *
* Next.js inlines both `process.env` checks at build time and Terser folds * In a production build with no flag set, both `process.env` checks fold to
* `IS_DEBUG` to a boolean literal, dead-code-eliminating the block in regular * `false` and the `window` check short-circuits at runtime, so the guarded
* production builds. Simple scalar reads are cheap enough to leave unguarded. * block is effectively a no-op.
* *
* Output format is logfmt-ish so logs are flat and grep/copy-friendly: * 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] 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: * Usage:
* const debug = createDebugLogger("git-status"); * const debug = createDebugLogger("git-status:ws");
* debug("status_update received", { sessionId, fileCount }); * debug("status_update received", { sessionId, fileCount });
* *
* Logs go through `console.debug`, which the log interceptor mirrors into the * Logs go through `console.debug`, which the log interceptor mirrors into the
@@ -32,7 +68,9 @@
export type DebugLogger = (...args: unknown[]) => void; export type DebugLogger = (...args: unknown[]) => void;
export const IS_DEBUG = 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 = () => {}; const NOOP: DebugLogger = () => {};
+40
View File
@@ -1,6 +1,19 @@
import type { BackendMessageMap, BackendMessageType } from "@/lib/types/backend"; import type { BackendMessageMap, BackendMessageType } from "@/lib/types/backend";
import type { ConnectionStatus } from "@/lib/types/connection"; import type { ConnectionStatus } from "@/lib/types/connection";
import { generateUUID } from "@/lib/utils"; 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; type MessageHandler<T extends BackendMessageType> = (message: BackendMessageMap[T]) => void;
@@ -115,6 +128,20 @@ export class WebSocketClient {
send(payload: unknown) { send(payload: unknown) {
const data = JSON.stringify(payload); 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) { if (this.status !== "connected" || !this.socket) {
this.pendingQueue.push(data); this.pendingQueue.push(data);
return; 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]) { private handleParsedMessage(message: BackendMessageMap[BackendMessageType]) {
const msgWithId = message as { id?: string; type: string }; const msgWithId = message as { id?: string; type: string };
if (msgWithId.type === "response" && msgWithId.id) { if (msgWithId.type === "response" && msgWithId.id) {
if (IS_DEBUG) debugDispatch("response", { id: msgWithId.id });
this.resolvePendingRequest(msgWithId.id, message.payload); this.resolvePendingRequest(msgWithId.id, message.payload);
return; return;
} }
if (msgWithId.type === "error" && msgWithId.id) { if (msgWithId.type === "error" && msgWithId.id) {
if (IS_DEBUG) debugDispatch("error-response", { id: msgWithId.id });
this.rejectPendingRequest(msgWithId.id, message.payload); this.rejectPendingRequest(msgWithId.id, message.payload);
return; return;
} }
@@ -340,6 +379,7 @@ export class WebSocketClient {
const action = (message as { action?: string })?.action as BackendMessageType | undefined; const action = (message as { action?: string })?.action as BackendMessageType | undefined;
if (!action) return; if (!action) return;
const handlers = this.handlers.get(action); const handlers = this.handlers.get(action);
this.debugNotification(action, message.payload, handlers?.size ?? 0);
if (handlers) { if (handlers) {
handlers.forEach((handler) => handler(message)); handlers.forEach((handler) => handler(message));
} }
+10
View File
@@ -67,6 +67,14 @@ const gitEventHandlers: GitEventHandlers = {
}, },
commit_created: (store, event) => { 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, { store.getState().addSessionCommit(event.session_id, {
id: event.commit.id, id: event.commit.id,
session_id: event.session_id, session_id: event.session_id,
@@ -88,6 +96,7 @@ const gitEventHandlers: GitEventHandlers = {
}, },
commits_reset: (store, event) => { commits_reset: (store, event) => {
if (IS_DEBUG) debug("commits_reset", { sessionId: event.session_id });
// Clear commits to trigger refetch in useSessionCommits hook // Clear commits to trigger refetch in useSessionCommits hook
store.getState().clearSessionCommits(event.session_id); store.getState().clearSessionCommits(event.session_id);
// Invalidate cumulative diff cache when commits are reset // Invalidate cumulative diff cache when commits are reset
@@ -95,6 +104,7 @@ const gitEventHandlers: GitEventHandlers = {
}, },
branch_switched: (store, event) => { branch_switched: (store, event) => {
if (IS_DEBUG) debug("branch_switched", { sessionId: event.session_id });
// Clear commits to trigger refetch with new base commit // Clear commits to trigger refetch with new base commit
store.getState().clearSessionCommits(event.session_id); store.getState().clearSessionCommits(event.session_id);
// Invalidate cumulative diff cache when branch switches // Invalidate cumulative diff cache when branch switches
@@ -22,20 +22,6 @@ export function registerSessionModelsHandlers(store: StoreApi<AppState>): WsHand
currentModelId = modelOpt.current_value; 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, { store.getState().setSessionModels(payload.session_id, {
currentModelId, currentModelId,
models: acpModels.map((m) => ({ models: acpModels.map((m) => ({
@@ -61,10 +47,6 @@ export function registerSessionModelsHandlers(store: StoreApi<AppState>): WsHand
const state = store.getState(); const state = store.getState();
const currentActive = state.activeModel.bySessionId[payload.session_id]; const currentActive = state.activeModel.bySessionId[payload.session_id];
if (currentActive && !acpModels.some((m) => m.model_id === currentActive)) { 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, ""); state.setActiveModel(payload.session_id, "");
} }
} }