diff --git a/apps/web/components/task/file-browser-actions.ts b/apps/web/components/task/file-browser-actions.ts new file mode 100644 index 0000000000..9bd8f79137 --- /dev/null +++ b/apps/web/components/task/file-browser-actions.ts @@ -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, + tree: FileTreeNode | null, +) { + const scrollSaveTimeoutRef = useRef(null); + const hasRestoredScrollRef = useRef(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, +) { + 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; + 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 { + 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["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" }); + } +} diff --git a/apps/web/components/task/file-browser-hooks.ts b/apps/web/components/task/file-browser-hooks.ts index 232a4893bc..a182a6a67f 100644 --- a/apps/web/components/task/file-browser-hooks.ts +++ b/apps/web/components/task/file-browser-hooks.ts @@ -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>; }; +// Thin wrapper so loadTree callers don't each pay a complexity point for IS_DEBUG. +function logLoad(event: string, data: Record) { + 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; + loadStateRef: React.MutableRefObject; + treeRef: React.MutableRefObject; + retryAttemptRef: React.MutableRefObject; + hasInitializedExpandedRef: React.MutableRefObject; + clearRetryTimer: () => void; + loadTree: (options?: { resetRetry?: boolean }) => Promise | void; + setTree: React.Dispatch>; + setIsLoadingTree: React.Dispatch>; + setLoadState: React.Dispatch>; + setLoadError: React.Dispatch>; + setExpandedPaths: (paths: Set) => 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, - tree: FileTreeNode | null, -) { - const scrollSaveTimeoutRef = useRef(null); - const hasRestoredScrollRef = useRef(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, -) { - 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; - 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 { - 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["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"; diff --git a/apps/web/hooks/domains/session/use-session-agentctl.ts b/apps/web/hooks/domains/session/use-session-agentctl.ts index 6b1d1adbc7..4cb266a766 100644 --- a/apps/web/hooks/domains/session/use-session-agentctl.ts +++ b/apps/web/hooks/domains/session/use-session-agentctl.ts @@ -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(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", }; } diff --git a/apps/web/hooks/domains/session/use-session-git-status.ts b/apps/web/hooks/domains/session/use-session-git-status.ts index 17832707e8..bcc2ee1dbe 100644 --- a/apps/web/hooks/domains/session/use-session-git-status.ts +++ b/apps/web/hooks/domains/session/use-session-git-status.ts @@ -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; diff --git a/apps/web/lib/debug/log.test.ts b/apps/web/lib/debug/log.test.ts index 0d2b8a7cec..e1a5231a11 100644 --- a/apps/web/lib/debug/log.test.ts +++ b/apps/web/lib/debug/log.test.ts @@ -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); + }); +}); diff --git a/apps/web/lib/debug/log.ts b/apps/web/lib/debug/log.ts index 1f33334475..9fb9ea24bb 100644 --- a/apps/web/lib/debug/log.ts +++ b/apps/web/lib/debug/log.ts @@ -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 `:` 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 = () => {}; diff --git a/apps/web/lib/ws/client.ts b/apps/web/lib/ws/client.ts index 07bccb4cd6..c0fd9872af 100644 --- a/apps/web/lib/ws/client.ts +++ b/apps/web/lib/ws/client.ts @@ -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([ + "session.message.added", + "session.message.updated", + "session.shell.output", + "session.process.output", +]); type MessageHandler = (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)); } diff --git a/apps/web/lib/ws/handlers/git-status.ts b/apps/web/lib/ws/handlers/git-status.ts index 7a1c1181a5..1c1b09c23f 100644 --- a/apps/web/lib/ws/handlers/git-status.ts +++ b/apps/web/lib/ws/handlers/git-status.ts @@ -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 diff --git a/apps/web/lib/ws/handlers/session-models.ts b/apps/web/lib/ws/handlers/session-models.ts index 2531335d82..c0bed49420 100644 --- a/apps/web/lib/ws/handlers/session-models.ts +++ b/apps/web/lib/ws/handlers/session-models.ts @@ -22,20 +22,6 @@ export function registerSessionModelsHandlers(store: StoreApi): 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): 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, ""); } }