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 { 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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 = () => {};
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user