{
bgColor: 'bg-rose-500/10',
borderColor: 'border-rose-500/30',
};
+ case 'stopped':
+ return {
+ icon:
,
+ color: 'text-amber-300',
+ bgColor: 'bg-amber-500/10',
+ borderColor: 'border-amber-500/30',
+ };
default:
return {
icon:
,
diff --git a/gitnexus-web/src/core/llm/agent.ts b/gitnexus-web/src/core/llm/agent.ts
index 5153e35fb..4f79d1c95 100644
--- a/gitnexus-web/src/core/llm/agent.ts
+++ b/gitnexus-web/src/core/llm/agent.ts
@@ -363,8 +363,17 @@ export type AgentMessage = { role: 'user'; content: string } | AgentHistoryMessa
export interface AgentRuntimeOptions {
/** Capture assistant/tool messages for providers that require exact transcript replay. */
captureHistory?: boolean;
+ /** When aborted (e.g. user clicked Stop), the stream ends with a `cancelled` chunk. */
+ signal?: AbortSignal;
}
+const isAbortError = (error: unknown, signal?: AbortSignal): boolean => {
+ if (error instanceof DOMException && error.name === 'AbortError') return true;
+ if (error instanceof Error && error.name === 'AbortError') return true;
+ if (signal?.aborted) return true;
+ return false;
+};
+
export const buildLangChainMessages = (messages: AgentMessage[]): BaseMessage[] =>
messages.map((message) => {
if (message.role === 'user') {
@@ -438,6 +447,7 @@ export async function* streamAgentResponse(
streamMode: ['values', 'messages'] as any,
// Allow longer tool/reasoning loops (more Cursor-like persistence)
recursionLimit: 50,
+ signal: options.signal,
} as any);
// Track what we've yielded to avoid duplicates
@@ -455,6 +465,10 @@ export async function* streamAgentResponse(
let lastStepMessages: any[] | null = null;
for await (const event of stream) {
+ if (options.signal?.aborted) {
+ break;
+ }
+
// Events come as [streamMode, data] tuples when using multiple modes
// or just data when using single mode
let mode: string;
@@ -511,10 +525,11 @@ export async function* streamAgentResponse(
// - After all tools are done: treat as final content
const isReasoning =
!hasSeenToolCallThisTurn || toolCalls.length > 0 || pendingToolCalls > 0;
- yield {
- type: isReasoning ? 'reasoning' : 'content',
- [isReasoning ? 'reasoning' : 'content']: content,
- };
+ if (isReasoning) {
+ yield { type: 'reasoning', reasoning: content };
+ } else {
+ yield { type: 'content', content };
+ }
}
// Track tool calls from message chunks
@@ -627,6 +642,11 @@ export async function* streamAgentResponse(
}
}
+ if (options.signal?.aborted) {
+ yield { type: 'cancelled' };
+ return;
+ }
+
// DEBUG: Stream completed normally
if (import.meta.env.DEV) {
console.log('✅ Stream completed normally, yielding done');
@@ -640,6 +660,10 @@ export async function* streamAgentResponse(
: undefined,
};
} catch (error) {
+ if (isAbortError(error, options.signal)) {
+ yield { type: 'cancelled' };
+ return;
+ }
const message = error instanceof Error ? error.message : String(error);
// DEBUG: Stream error
if (import.meta.env.DEV) {
diff --git a/gitnexus-web/src/core/llm/types.ts b/gitnexus-web/src/core/llm/types.ts
index c568c7d93..b7727da10 100644
--- a/gitnexus-web/src/core/llm/types.ts
+++ b/gitnexus-web/src/core/llm/types.ts
@@ -254,7 +254,7 @@ export interface ToolCallInfo {
name: string;
args: Record
;
result?: string;
- status: 'pending' | 'running' | 'completed' | 'error';
+ status: 'pending' | 'running' | 'completed' | 'error' | 'stopped';
}
/**
@@ -286,22 +286,17 @@ export type AgentHistoryMessage =
};
/**
- * Streaming chunk from agent
- * Now supports step-based streaming where each step is a distinct message
+ * Streaming chunk from agent (discriminated union).
+ * Each variant carries only its relevant fields, enabling exhaustive switch handling.
*/
-export interface AgentStreamChunk {
- type: 'reasoning' | 'tool_call' | 'tool_result' | 'content' | 'error' | 'done';
- /** LLM's reasoning/thinking text (shown as a step) */
- reasoning?: string;
- /** Final answer content (streamed token by token) */
- content?: string;
- /** Hidden raw transcript for reconstructing future agent turns */
- historyMessages?: AgentHistoryMessage[];
- /** Tool call information */
- toolCall?: ToolCallInfo;
- /** Error message */
- error?: string;
-}
+export type AgentStreamChunk =
+ | { type: 'reasoning'; reasoning: string }
+ | { type: 'tool_call'; toolCall: ToolCallInfo }
+ | { type: 'tool_result'; toolCall: ToolCallInfo }
+ | { type: 'content'; content: string }
+ | { type: 'error'; error: string }
+ | { type: 'done'; historyMessages?: AgentHistoryMessage[] }
+ | { type: 'cancelled' };
/**
* A single step in the agent's execution
diff --git a/gitnexus-web/src/hooks/app-state/graph.test.tsx b/gitnexus-web/src/hooks/app-state/graph.test.tsx
new file mode 100644
index 000000000..12d946ee2
--- /dev/null
+++ b/gitnexus-web/src/hooks/app-state/graph.test.tsx
@@ -0,0 +1,22 @@
+import { describe, it, expect } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { GraphStateProvider, useGraphState } from './graph';
+
+function wrapper({ children }: { children: React.ReactNode }) {
+ return {children};
+}
+
+describe('GraphState', () => {
+ it('should have default graphViewMode as "force"', () => {
+ const { result } = renderHook(() => useGraphState(), { wrapper });
+ expect(result.current.graphViewMode).toBe('force');
+ });
+
+ it('should toggle graphViewMode', () => {
+ const { result } = renderHook(() => useGraphState(), { wrapper });
+ act(() => {
+ result.current.setGraphViewMode('tree');
+ });
+ expect(result.current.graphViewMode).toBe('tree');
+ });
+});
diff --git a/gitnexus-web/src/hooks/app-state/graph.tsx b/gitnexus-web/src/hooks/app-state/graph.tsx
index 4aa41db01..aa6524a5f 100644
--- a/gitnexus-web/src/hooks/app-state/graph.tsx
+++ b/gitnexus-web/src/hooks/app-state/graph.tsx
@@ -16,6 +16,8 @@ interface GraphStateContextValue {
setDepthFilter: (depth: number | null) => void;
highlightedNodeIds: Set;
setHighlightedNodeIds: (ids: Set) => void;
+ graphViewMode: 'force' | 'tree' | 'circles';
+ setGraphViewMode: (mode: 'force' | 'tree' | 'circles') => void;
}
const GraphStateContext = createContext(null);
@@ -27,6 +29,7 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
const [visibleEdgeTypes, setVisibleEdgeTypes] = useState(DEFAULT_VISIBLE_EDGES);
const [depthFilter, setDepthFilter] = useState(null);
const [highlightedNodeIds, setHighlightedNodeIds] = useState>(new Set());
+ const [graphViewMode, setGraphViewMode] = useState<'force' | 'tree' | 'circles'>('force');
const toggleLabelVisibility = useCallback((label: NodeLabel) => {
setVisibleLabels((prev) =>
@@ -54,8 +57,18 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
setDepthFilter,
highlightedNodeIds,
setHighlightedNodeIds,
+ graphViewMode,
+ setGraphViewMode,
}),
- [graph, selectedNode, visibleLabels, visibleEdgeTypes, depthFilter, highlightedNodeIds],
+ [
+ graph,
+ selectedNode,
+ visibleLabels,
+ visibleEdgeTypes,
+ depthFilter,
+ highlightedNodeIds,
+ graphViewMode,
+ ],
);
return {children};
diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx
index 5a7e85457..5c118450b 100644
--- a/gitnexus-web/src/hooks/useAppState.tsx
+++ b/gitnexus-web/src/hooks/useAppState.tsx
@@ -123,6 +123,10 @@ interface AppState {
depthFilter: number | null;
setDepthFilter: (depth: number | null) => void;
+ // Graph view mode
+ graphViewMode: 'force' | 'tree' | 'circles';
+ setGraphViewMode: (mode: 'force' | 'tree' | 'circles') => void;
+
// Query state
highlightedNodeIds: Set;
setHighlightedNodeIds: (ids: Set) => void;
@@ -232,6 +236,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setDepthFilter,
highlightedNodeIds,
setHighlightedNodeIds,
+ graphViewMode,
+ setGraphViewMode,
} = useGraphState();
// Right Panel
@@ -582,6 +588,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
// Agent state — agent runs on main thread now (I/O-bound, not CPU-bound)
const agentRef = useRef(null);
+ const chatAbortRef = useRef(null);
+ const chatStateRef = useRef<'idle' | 'streaming' | 'aborting'>('idle');
const initializeAgent = useCallback(
async (overrideProjectName?: string): Promise => {
@@ -640,7 +648,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
const sendChatMessage = useCallback(
async (message: string): Promise => {
- if (isChatLoading) return;
+ if (chatStateRef.current !== 'idle') return;
// Refresh Code panel for the new question: keep user-pinned refs, clear old AI citations
clearAICodeReferences();
@@ -679,8 +687,13 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
}
setIsChatLoading(true);
+ chatStateRef.current = 'streaming';
setCurrentToolCalls([]);
+ chatAbortRef.current?.abort();
+ const chatAbortController = new AbortController();
+ chatAbortRef.current = chatAbortController;
+
const providerCapabilities = getProviderCapabilities(llmSettings.activeProvider);
// Prepare message history for agent (convert our format to AgentMessage format)
@@ -736,11 +749,13 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
});
};
let pendingUpdate = false;
+ let rafHandle: number | null = null;
const scheduleMessageUpdate = () => {
if (pendingUpdate) return;
pendingUpdate = true;
- requestAnimationFrame(() => {
+ rafHandle = requestAnimationFrame(() => {
pendingUpdate = false;
+ rafHandle = null;
updateMessage();
});
};
@@ -887,7 +902,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
if (idx < 0) {
idx = toolCallsForMessage.findIndex((t) => t.name === tc.name && !t.result);
}
- if (idx >= 0) {
+ if (idx >= 0 && toolCallsForMessage[idx].status !== 'stopped') {
toolCallsForMessage[idx] = {
...toolCallsForMessage[idx],
result: tc.result,
@@ -903,7 +918,11 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
(s.toolCall.id === tc.id ||
(s.toolCall.name === tc.name && s.toolCall.status === 'running')),
);
- if (stepIdx >= 0 && stepsForMessage[stepIdx].toolCall) {
+ if (
+ stepIdx >= 0 &&
+ stepsForMessage[stepIdx].toolCall &&
+ stepsForMessage[stepIdx].toolCall!.status !== 'stopped'
+ ) {
stepsForMessage[stepIdx] = {
...stepsForMessage[stepIdx],
toolCall: {
@@ -924,6 +943,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
targetIdx = prev.findIndex((t) => t.name === tc.name && !t.result);
}
if (targetIdx >= 0) {
+ const target = prev[targetIdx];
+ if (target.status === 'stopped') return prev;
return prev.map((t, i) =>
i === targetIdx ? { ...t, result: tc.result, status: 'completed' } : t,
);
@@ -1022,13 +1043,24 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
const { streamAgentResponse } = await import('../core/llm/agent');
for await (const chunk of streamAgentResponse(agent, history, {
captureHistory: providerCapabilities.preserveAssistantTranscript,
+ signal: chatAbortController.signal,
})) {
+ if (chunk.type === 'cancelled') {
+ break;
+ }
onChunk(chunk);
}
} catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- setAgentError(message);
+ if (!chatAbortController.signal.aborted) {
+ const message = error instanceof Error ? error.message : String(error);
+ setAgentError(message);
+ }
} finally {
+ if (rafHandle != null) {
+ cancelAnimationFrame(rafHandle);
+ rafHandle = null;
+ }
+ chatStateRef.current = 'idle';
setIsChatLoading(false);
setCurrentToolCalls([]);
}
@@ -1044,22 +1076,56 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
clearAIToolHighlights,
graph,
embeddingStatus,
- isChatLoading,
],
);
const stopChatResponse = useCallback(() => {
- if (isChatLoading) {
- // Agent streaming will be interrupted by the AbortController in sendChatMessage
- setIsChatLoading(false);
- setCurrentToolCalls([]);
- }
- }, [isChatLoading]);
+ if (!chatAbortRef.current) return;
+
+ chatStateRef.current = 'aborting';
+ chatAbortRef.current.abort();
+ chatAbortRef.current = null;
+
+ const stoppedLabel = i18n.t('chat:stopped');
+ const markStoppedToolCall = (tc: ToolCallInfo): ToolCallInfo =>
+ tc.status === 'running' || tc.status === 'pending'
+ ? { ...tc, status: 'stopped', result: stoppedLabel }
+ : tc;
+
+ setCurrentToolCalls((prev) => prev.map(markStoppedToolCall));
+
+ setChatMessages((prev) => {
+ const lastAssistantIdx = [...prev]
+ .map((m, i) => (m.role === 'assistant' ? i : -1))
+ .filter((i) => i >= 0)
+ .pop();
+ if (lastAssistantIdx === undefined) return prev;
+
+ const message = prev[lastAssistantIdx];
+
+ const updated: ChatMessage = {
+ ...message,
+ toolCalls: message.toolCalls?.map(markStoppedToolCall),
+ steps: message.steps?.map((step) =>
+ step.type === 'tool_call' && step.toolCall
+ ? { ...step, toolCall: markStoppedToolCall(step.toolCall) }
+ : step,
+ ),
+ };
+ return prev.map((m, i) => (i === lastAssistantIdx ? updated : m));
+ });
+
+ setIsChatLoading(false);
+ }, []);
const clearChat = useCallback(() => {
+ chatAbortRef.current?.abort();
+ chatAbortRef.current = null;
+ chatStateRef.current = 'idle';
setChatMessages([]);
setCurrentToolCalls([]);
setAgentError(null);
+ setIsChatLoading(false);
}, []);
// Switch to a different repo on the connected server
@@ -1266,6 +1332,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
toggleEdgeVisibility,
depthFilter,
setDepthFilter,
+ graphViewMode,
+ setGraphViewMode,
highlightedNodeIds,
setHighlightedNodeIds,
aiCitationHighlightedNodeIds,
diff --git a/gitnexus-web/src/hooks/useSigma.ts b/gitnexus-web/src/hooks/useSigma.ts
index 461de0f5a..a2121dad4 100644
--- a/gitnexus-web/src/hooks/useSigma.ts
+++ b/gitnexus-web/src/hooks/useSigma.ts
@@ -62,6 +62,7 @@ interface UseSigmaOptions {
blastRadiusNodeIds?: Set;
animatedNodes?: Map;
visibleEdgeTypes?: EdgeType[];
+ layoutMode?: 'force' | 'tree' | 'circles';
}
interface UseSigmaReturn {
@@ -128,6 +129,105 @@ const getLayoutDuration = (nodeCount: number): number => {
return 20000; // 20s for small graphs
};
+const TREE_MAX_X = 540;
+const TREE_REPULSION_RANGE = 130;
+const TREE_LAYOUT_MAX_DURATION = 18000;
+const TREE_LAYOUT_STABILITY_FRAMES = 24;
+const TREE_TARGET_FRAME_MS = 32;
+const TREE_LAYOUT_MIN_DURATION = 1500;
+const TREE_FORCE_DEADZONE = 0.005;
+const TREE_VELOCITY_DEADZONE = 0.01;
+// Y is free within each layer's band; gravity + boundary resistance keep layers separate.
+// Band half kept at 55px so nodes don't drift far past the initial camera-fit viewport.
+const TREE_LAYER_GRAVITY = 0.06; // stronger gravity keeps nodes near their layer center
+const TREE_LAYER_BAND_HALF = 55; // ±55px from layer center Y
+const TREE_LAYER_BOUNDARY_RESISTANCE = 10; // progressive resistance near band edges
+// Spread force: fine-tune density within each layer during physics.
+// Kept deliberately weak (0.003) because the initial proportional layout already
+// distributes nodes near their ideal positions — aggressive spread would fight
+// the hierarchy springs and push edge-parented children away from their parents.
+const TREE_SPREAD_STRENGTH = 0.003;
+
+// ---------------------------------------------------------------------------
+// Circles View constants
+// ---------------------------------------------------------------------------
+
+/** Target radius for each ring — must match CIRCLES_RING_RADII in circles-layout.ts */
+const CIRCLES_RING_RADII = [90, 240, 420, 620] as const;
+const CIRCLES_RING_COUNT = CIRCLES_RING_RADII.length;
+
+/**
+ * Half-width of the allowed radial band. Must match CIRCLES_BAND_HALF in
+ * circles-layout.ts. Keep it small enough that adjacent ring bands never
+ * overlap: current ring gaps are 150/180/200 px, so 45 px leaves 60-110 px
+ * of clear air between rings.
+ *
+ * Nodes distribute within this band driven by repulsion (outward) and
+ * soft-wall gravity (inward, growing cubically near the edge).
+ * No hard clamp — nodes float freely inside the band.
+ */
+const CIRCLES_BAND_HALF = 45;
+
+/**
+ * Base radial gravity rate. Effective gravity grows cubically near the band
+ * edge via CIRCLES_RADIAL_BOUNDARY_RESISTANCE:
+ *
+ * rOffset = 0 px → k = k_base × 1 (almost no pull)
+ * rOffset = 22 px → k ≈ k_base × 4.2 (moderate)
+ * rOffset = 40 px → k ≈ k_base × 16 (strong)
+ * rOffset = 45 px → k ≈ k_base × 21 (very strong — prevents crossing)
+ */
+const CIRCLES_RADIAL_GRAVITY = 0.06;
+
+/**
+ * Cubic-growth multiplier near the band edge.
+ * Effective k = CIRCLES_RADIAL_GRAVITY × (1 + normR³ × this).
+ */
+const CIRCLES_RADIAL_BOUNDARY_RESISTANCE = 22;
+
+/**
+ * Angular spread force: kept very weak — edge springs are the primary
+ * mechanism for angular positioning. A too-strong spread competes with
+ * springs and keeps connected nodes far apart.
+ */
+const CIRCLES_ANGULAR_SPREAD = 0.002;
+
+/** Repulsion range — same as tree view so nodes from dense rings don't clump. */
+const CIRCLES_REPULSION_RANGE = 130;
+
+const CIRCLES_LAYOUT_MAX_DURATION = 24000;
+const CIRCLES_LAYOUT_STABILITY_FRAMES = 24;
+const CIRCLES_LAYOUT_MIN_DURATION = 1500;
+const CIRCLES_FORCE_DEADZONE = 0.005;
+const CIRCLES_VELOCITY_DEADZONE = 0.01;
+
+const CIRCLES_EDGE_WEIGHTS: Record = {
+ // Hierarchy edges: moderate — angular alignment without fighting radial gravity
+ // (rest length is now set to ring-gap distance, not zero).
+ CONTAINS: 0.18,
+ DEFINES: 0.22,
+ // Cross edges: stronger so same-ring connected nodes cluster angularly.
+ IMPORTS: 0.2,
+ CALLS: 0.24,
+ EXTENDS: 0.2,
+ IMPLEMENTS: 0.2,
+};
+
+// ---------------------------------------------------------------------------
+
+const TREE_EDGE_WEIGHTS: Record = {
+ CONTAINS: 0.09,
+ DEFINES: 0.12,
+ IMPORTS: 0.14,
+ CALLS: 0.18,
+ EXTENDS: 0.13,
+ IMPLEMENTS: 0.13,
+};
+
+const clamp = (value: number, min: number, max: number): number => {
+ return Math.min(max, Math.max(min, value));
+};
+
export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
const containerRef = useRef(null);
const sigmaRef = useRef(null);
@@ -138,8 +238,33 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
const blastRadiusRef = useRef>(new Set());
const animatedNodesRef = useRef