From 548b2b83a433d3eda1e5b94794dec4700dca39df Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Fri, 29 Aug 2025 16:38:19 +0000 Subject: [PATCH 1/5] refactor(ui): optimize menu hover width and simplify text layout (#396) Before ![image.png](https://app.graphite.dev/user-attachments/assets/a29a075c-52fc-421d-b3ab-d19163a0cade.png) After ![image.png](https://app.graphite.dev/user-attachments/assets/2b2584ea-cb07-44ed-bec4-c3b23e70b4d2.png) - Reduced menu hover width from 220px to 160px - Simplified text positioning from absolute positioning to padding-left - Removed the usage limit warning badge from the desktop menu --- apps/web/components/menu.tsx | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/apps/web/components/menu.tsx b/apps/web/components/menu.tsx index 926b2a7a..f59617c0 100644 --- a/apps/web/components/menu.tsx +++ b/apps/web/components/menu.tsx @@ -159,7 +159,7 @@ function Menu({ id }: { id?: string }) { }, [isMobile, isMobileMenuOpen, isHovered, expandedView, setMenuExpanded]); // Calculate width based on state - const menuWidth = expandedView || isCollapsing ? 600 : isHovered ? 220 : 56; + const menuWidth = expandedView || isCollapsing ? 600 : isHovered ? 160 : 56; // Dynamic z-index for mobile based on active panel const mobileZIndex = @@ -283,7 +283,7 @@ function Menu({ id }: { id?: string }) { opacity: isHovered ? 1 : 0, x: isHovered ? 0 : -10, }} - className="drop-shadow-lg absolute left-10 right-16 whitespace-nowrap" + className="drop-shadow-lg pl-3 whitespace-nowrap" initial={{ opacity: 0, x: -10 }} style={{ transform: "translateZ(0)", @@ -296,20 +296,6 @@ function Menu({ id }: { id?: string }) { > {item.text} - {shouldShowLimitWarning && item.key === "addUrl" && ( - - {memoriesLimit - memoriesUsed} left - - )} {index === 0 && ( Date: Fri, 29 Aug 2025 11:24:53 -0700 Subject: [PATCH 2/5] Add claude GitHub actions 1756491853286 (#397) --- .github/workflows/claude-code-review.yml | 54 ++++++++++++++++++++++++ .github/workflows/claude.yml | 50 ++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 .github/workflows/claude-code-review.yml create mode 100644 .github/workflows/claude.yml diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 00000000..5e90d4b9 --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,54 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: | + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + + Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. + + Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. + + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options + claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 00000000..4b2e6d2f --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options + # claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)' + From 5b7f9ceb44decc088c7db7c50756bae55f019558 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Fri, 29 Aug 2025 19:45:47 +0000 Subject: [PATCH 3/5] feat: migrate from react-markdown to streamdown (#394) | Before (react-markdown) | After (streamdown) | | --- | --- | | Before: react-markdown rendering | After: streamdown rendering | ## Changes Made - **Dependencies**: Removed `react-markdown` and `remark-gfm`, added `streamdown@^1.1.6` - **Component Updates**: - Updated chat message rendering to use `` component - Maintained all existing functionality for tool state rendering - Preserved prose styling classes for consistent appearance - **Code Quality Improvements**: - Fixed TypeScript type issues with message parts - Improved switch case structure with proper default cases - Replaced array index-based keys with stable message-based keys - Added `useCallback` for performance optimization - Fixed biome linting issues and switch case fallthrough warnings --- apps/web/biome.json | 5 +- .../components/views/chat/chat-messages.tsx | 96 +++--- apps/web/package.json | 3 +- biome.json | 6 +- bun.lock | 293 +++++++++++++++++- packages/ui/biome.json | 2 +- 6 files changed, 344 insertions(+), 61 deletions(-) diff --git a/apps/web/biome.json b/apps/web/biome.json index ea994ee6..48649190 100644 --- a/apps/web/biome.json +++ b/apps/web/biome.json @@ -1,6 +1,7 @@ { "root": false, - "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", + "extends": "//", + "$schema": "https://biomejs.dev/schemas/2.2.2/schema.json", "linter": { "rules": { "nursery": { @@ -8,4 +9,4 @@ } } } -} +} \ No newline at end of file diff --git a/apps/web/components/views/chat/chat-messages.tsx b/apps/web/components/views/chat/chat-messages.tsx index ab228ce3..cff7e1b7 100644 --- a/apps/web/components/views/chat/chat-messages.tsx +++ b/apps/web/components/views/chat/chat-messages.tsx @@ -7,9 +7,8 @@ import { Input } from "@ui/components/input"; import { DefaultChatTransport } from "ai"; import { ArrowUp, Check, Copy, RotateCcw, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; import { toast } from "sonner"; +import { Streamdown } from "streamdown"; import { TextShimmer } from "@/components/text-shimmer"; import { usePersistentChat, useProject } from "@/stores"; import { useGraphHighlights } from "@/stores/highlights"; @@ -21,10 +20,10 @@ function useStickyAutoScroll(triggerKeys: ReadonlyArray) { const [isAutoScroll, setIsAutoScroll] = useState(true); const [isFarFromBottom, setIsFarFromBottom] = useState(false); - function scrollToBottom(behavior: ScrollBehavior = "auto") { + const scrollToBottom = (behavior: ScrollBehavior = "auto") => { const node = bottomRef.current; if (node) node.scrollIntoView({ behavior, block: "end" }); - } + }; useEffect(function observeBottomVisibility() { const container = scrollContainerRef.current; @@ -67,20 +66,20 @@ function useStickyAutoScroll(triggerKeys: ReadonlyArray) { function autoScrollOnNewContent() { if (isAutoScroll) scrollToBottom("auto"); }, - [isAutoScroll, ...triggerKeys], + [isAutoScroll, scrollToBottom, ...triggerKeys], ); - function recomputeDistanceFromBottom() { + const recomputeDistanceFromBottom = () => { const container = scrollContainerRef.current; if (!container) return; const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; setIsFarFromBottom(distanceFromBottom > 100); - } + }; useEffect(() => { recomputeDistanceFromBottom(); - }, [...triggerKeys]); + }, [recomputeDistanceFromBottom, ...triggerKeys]); function onScroll() { recomputeDistanceFromBottom(); @@ -154,7 +153,6 @@ export function ChatMessages() { const msgs = getCurrentConversation(); setMessages(msgs ?? []); setInput(""); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentChatId]); useEffect(() => { @@ -208,7 +206,7 @@ export function ChatMessages() { currentSummary?.title && currentSummary.title.trim().length > 0, ); shouldGenerateTitleRef.current = !hasTitle; - }, [currentChatId, id, getCurrentChat]); + }, [getCurrentChat]); const { scrollContainerRef, bottomRef, @@ -222,17 +220,17 @@ export function ChatMessages() { <>
{messages.map((message) => (
{message.parts @@ -241,27 +239,22 @@ export function ChatMessages() { part.type, ), ) - .map((part, index) => { + .map((part) => { switch (part.type) { case "text": return ( -
- - {(part as any).text} - +
+ {part.text}
); - case "tool-searchMemories": + case "tool-searchMemories": { switch (part.state) { case "input-available": case "input-streaming": return (
Searching memories... @@ -270,44 +263,42 @@ export function ChatMessages() { case "output-error": return (
Error recalling memories
); case "output-available": { - const output = (part as any).output; + const output = part.output; const foundCount = typeof output === "object" && output !== null && "count" in output ? Number(output.count) || 0 : 0; - const ids = Array.isArray(output?.results) - ? ((output.results as any[]) - .map((r) => r?.documentId) - .filter(Boolean) as string[]) - : []; return (
Found {foundCount}{" "} memories
); } + default: + return null; } - case "tool-addMemory": + } + case "tool-addMemory": { switch (part.state) { case "input-available": return (
Adding memory...
@@ -315,8 +306,8 @@ export function ChatMessages() { case "output-error": return (
Error adding memory
@@ -324,8 +315,8 @@ export function ChatMessages() { case "output-available": return (
Memory added
@@ -333,23 +324,24 @@ export function ChatMessages() { case "input-streaming": return (
Adding memory...
); + default: + return null; } + } + default: + return null; } - - return null; })}
{message.role === "assistant" && (
@@ -387,11 +381,6 @@ export function ChatMessages() {
@@ -426,12 +420,12 @@ export function ChatMessages() {
setInput(e.target.value)} disabled={status === "submitted"} + onChange={(e) => setInput(e.target.value)} placeholder="Say something..." + value={input} /> - -
-
-
- - - ) : ( - - -
-
-
-

- No Memories to Visualize -

- -
-
-
-
-
- )} - + {/* Animated content switching */} + + {viewMode === "graph" ? ( + + +
+
+
+

+ No Memories to Visualize +

+ +
+
+
+
+
+ ) : ( + + +
+
+
+

+ No Memories to Visualize +

+ +
+
+
+
+
+ )} +
- {/* Top Bar */} -
-
- - - - + {/* Top Bar */} +
+
+ + + + -
- -
+
+ +
- - - -
+ + + +
-
- -
-
+
+ +
+
- {/* Floating Open Chat Button */} - {!isOpen && !isMobile && ( - - - - )} - + {/* Floating Open Chat Button */} + {!isOpen && !isMobile && ( + + + + )} + - {/* Chat panel - positioned absolutely */} - - - - - + {/* Chat panel - positioned absolutely */} + + + + + - {showAddMemoryView && ( - setShowAddMemoryView(false)} - /> - )} + {showAddMemoryView && ( + setShowAddMemoryView(false)} + /> + )} - {/* Tour Alert Dialog */} - + {/* Tour Alert Dialog */} + - {/* Referral/Upgrade Modal */} - setShowReferralModal(false)} - /> -
- ); + {/* Referral/Upgrade Modal */} + setShowReferralModal(false)} + /> +
+ ); }; // Wrapper component to handle auth and waitlist checks @@ -673,12 +673,7 @@ export default function Page() { const router = useRouter(); const { user } = useAuth(); - // Check waitlist status - const { - data: waitlistStatus, - isLoading: isCheckingWaitlist, - error: waitlistError, - } = useQuery({ + const { data: waitlistStatus, isLoading: isCheckingWaitlist } = useQuery({ queryKey: ["waitlist-status", user?.id], queryFn: async () => { try { diff --git a/packages/ui/memory-graph/graph-canvas.tsx b/packages/ui/memory-graph/graph-canvas.tsx index b29288ad..c4623c85 100644 --- a/packages/ui/memory-graph/graph-canvas.tsx +++ b/packages/ui/memory-graph/graph-canvas.tsx @@ -35,6 +35,9 @@ export const GraphCanvas = memo( onPanEnd, onWheel, onDoubleClick, + onTouchStart, + onTouchMove, + onTouchEnd, draggingNodeId, highlightDocumentIds, }) => { @@ -657,6 +660,10 @@ export const GraphCanvas = memo( onWheel({ deltaY: e.deltaY, deltaX: e.deltaX, + clientX: e.clientX, + clientY: e.clientY, + currentTarget: canvas, + nativeEvent: e, preventDefault: () => {}, stopPropagation: () => {}, } as React.WheelEvent); @@ -732,6 +739,9 @@ export const GraphCanvas = memo( onPanEnd(); } }} + onTouchStart={onTouchStart} + onTouchMove={onTouchMove} + onTouchEnd={onTouchEnd} ref={canvasRef} style={{ cursor: draggingNodeId diff --git a/packages/ui/memory-graph/graph-webgl-canvas.tsx b/packages/ui/memory-graph/graph-webgl-canvas.tsx index 9d775c2b..d45e75c8 100644 --- a/packages/ui/memory-graph/graph-webgl-canvas.tsx +++ b/packages/ui/memory-graph/graph-webgl-canvas.tsx @@ -28,6 +28,9 @@ export const GraphWebGLCanvas = memo( onPanEnd, onWheel, onDoubleClick, + onTouchStart, + onTouchMove, + onTouchEnd, draggingNodeId, }) => { const containerRef = useRef(null); @@ -697,6 +700,10 @@ export const GraphWebGLCanvas = memo( onWheel({ deltaY: dy, deltaX: dx, + clientX: e.clientX, + clientY: e.clientY, + currentTarget: containerRef.current, + nativeEvent: e.nativeEvent, preventDefault: () => {}, stopPropagation: () => {}, } as React.WheelEvent); @@ -739,6 +746,9 @@ export const GraphWebGLCanvas = memo( }} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} + onTouchStart={onTouchStart} + onTouchMove={onTouchMove} + onTouchEnd={onTouchEnd} onWheel={handleWheel} ref={containerRef} role="application" diff --git a/packages/ui/memory-graph/hooks/use-graph-interactions.ts b/packages/ui/memory-graph/hooks/use-graph-interactions.ts index 62216068..6f0317d2 100644 --- a/packages/ui/memory-graph/hooks/use-graph-interactions.ts +++ b/packages/ui/memory-graph/hooks/use-graph-interactions.ts @@ -1,31 +1,95 @@ -"use client"; +"use client" -import { useCallback, useState } from "react"; -import { GRAPH_SETTINGS } from "../constants"; -import type { GraphNode } from "../types"; +import { useCallback, useRef, useState } from "react" +import { GRAPH_SETTINGS } from "../constants" +import type { GraphNode } from "../types" export function useGraphInteractions( variant: "console" | "consumer" = "console", ) { - const settings = GRAPH_SETTINGS[variant]; + const settings = GRAPH_SETTINGS[variant] - const [panX, setPanX] = useState(settings.initialPanX); - const [panY, setPanY] = useState(settings.initialPanY); - const [zoom, setZoom] = useState(settings.initialZoom); - const [isPanning, setIsPanning] = useState(false); - const [panStart, setPanStart] = useState({ x: 0, y: 0 }); - const [hoveredNode, setHoveredNode] = useState(null); - const [selectedNode, setSelectedNode] = useState(null); - const [draggingNodeId, setDraggingNodeId] = useState(null); + const [panX, setPanX] = useState(settings.initialPanX) + const [panY, setPanY] = useState(settings.initialPanY) + const [zoom, setZoom] = useState(settings.initialZoom) + const [isPanning, setIsPanning] = useState(false) + const [panStart, setPanStart] = useState({ x: 0, y: 0 }) + const [hoveredNode, setHoveredNode] = useState(null) + const [selectedNode, setSelectedNode] = useState(null) + const [draggingNodeId, setDraggingNodeId] = useState(null) const [dragStart, setDragStart] = useState({ x: 0, y: 0, nodeX: 0, nodeY: 0, - }); + }) const [nodePositions, setNodePositions] = useState< Map - >(new Map()); + >(new Map()) + + // Touch gesture state + const [touchState, setTouchState] = useState<{ + touches: { id: number; x: number; y: number }[] + lastDistance: number + lastCenter: { x: number; y: number } + isGesturing: boolean + }>({ + touches: [], + lastDistance: 0, + lastCenter: { x: 0, y: 0 }, + isGesturing: false, + }) + + // Animation state for smooth transitions + const animationRef = useRef(null) + const [isAnimating, setIsAnimating] = useState(false) + + // Smooth animation helper + const animateToViewState = useCallback( + ( + targetPanX: number, + targetPanY: number, + targetZoom: number, + duration: number = 300, + ) => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + } + + const startPanX = panX + const startPanY = panY + const startZoom = zoom + const startTime = Date.now() + + setIsAnimating(true) + + const animate = () => { + const elapsed = Date.now() - startTime + const progress = Math.min(elapsed / duration, 1) + + // Ease out cubic function for smooth transitions + const easeOut = 1 - Math.pow(1 - progress, 3) + + const currentPanX = startPanX + (targetPanX - startPanX) * easeOut + const currentPanY = startPanY + (targetPanY - startPanY) * easeOut + const currentZoom = startZoom + (targetZoom - startZoom) * easeOut + + setPanX(currentPanX) + setPanY(currentPanY) + setZoom(currentZoom) + + if (progress < 1) { + animationRef.current = requestAnimationFrame(animate) + } else { + setIsAnimating(false) + animationRef.current = null + } + } + + animate() + }, + [panX, panY, zoom], + ) // Node drag handlers const handleNodeDragStart = useCallback( @@ -91,19 +155,110 @@ export function useGraphInteractions( }, []); // Zoom handlers - const handleWheel = useCallback((e: React.WheelEvent) => { - e.preventDefault(); - const delta = e.deltaY > 0 ? 0.97 : 1.03; - setZoom((prev) => Math.max(0.1, Math.min(2, prev * delta))); - }, []); + const handleWheel = useCallback( + (e: React.WheelEvent) => { + // Always prevent default to stop browser navigation + e.preventDefault() + e.stopPropagation() + + // Handle horizontal scrolling (trackpad swipe) by converting to pan + if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + // Horizontal scroll - pan the graph instead of zooming + const panDelta = e.deltaX * 0.5 + setPanX(prev => prev - panDelta) + return + } + + // Vertical scroll - zoom behavior + const delta = e.deltaY > 0 ? 0.97 : 1.03 + const newZoom = Math.max(0.05, Math.min(3, zoom * delta)) + + // Get mouse position relative to the viewport + let mouseX = e.clientX + let mouseY = e.clientY + + // Try to get the container bounds to make coordinates relative to the graph container + const target = e.currentTarget + if (target && 'getBoundingClientRect' in target) { + const rect = target.getBoundingClientRect() + mouseX = e.clientX - rect.left + mouseY = e.clientY - rect.top + } + + // Calculate the world position of the mouse cursor + const worldX = (mouseX - panX) / zoom + const worldY = (mouseY - panY) / zoom + + // Calculate new pan to keep the mouse position stationary + const newPanX = mouseX - worldX * newZoom + const newPanY = mouseY - worldY * newZoom + + setZoom(newZoom) + setPanX(newPanX) + setPanY(newPanY) + }, + [zoom, panX, panY], + ) - const zoomIn = useCallback(() => { - setZoom((prev) => Math.min(2, prev * 1.1)); - }, []); + const zoomIn = useCallback( + (centerX?: number, centerY?: number, animate: boolean = true) => { + const zoomFactor = 1.2 + const newZoom = Math.min(3, zoom * zoomFactor) // Increased max zoom to 3x + + if (centerX !== undefined && centerY !== undefined) { + // Mouse-centered zoom for programmatic zoom in + const worldX = (centerX - panX) / zoom + const worldY = (centerY - panY) / zoom + const newPanX = centerX - worldX * newZoom + const newPanY = centerY - worldY * newZoom + + if (animate && !isAnimating) { + animateToViewState(newPanX, newPanY, newZoom, 200) + } else { + setZoom(newZoom) + setPanX(newPanX) + setPanY(newPanY) + } + } else { + if (animate && !isAnimating) { + animateToViewState(panX, panY, newZoom, 200) + } else { + setZoom(newZoom) + } + } + }, + [zoom, panX, panY, isAnimating, animateToViewState], + ) - const zoomOut = useCallback(() => { - setZoom((prev) => Math.max(0.1, prev / 1.1)); - }, []); + const zoomOut = useCallback( + (centerX?: number, centerY?: number, animate: boolean = true) => { + const zoomFactor = 0.8 + const newZoom = Math.max(0.05, zoom * zoomFactor) // Decreased min zoom to 0.05x + + if (centerX !== undefined && centerY !== undefined) { + // Mouse-centered zoom for programmatic zoom out + const worldX = (centerX - panX) / zoom + const worldY = (centerY - panY) / zoom + const newPanX = centerX - worldX * newZoom + const newPanY = centerY - worldY * newZoom + + if (animate && !isAnimating) { + animateToViewState(newPanX, newPanY, newZoom, 200) + } else { + setZoom(newZoom) + setPanX(newPanX) + setPanY(newPanY) + } + } else { + if (animate && !isAnimating) { + animateToViewState(panX, panY, newZoom, 200) + } else { + setZoom(newZoom) + } + } + }, + [zoom, panX, panY, isAnimating, animateToViewState], + ) const resetView = useCallback(() => { setPanX(settings.initialPanX); @@ -153,41 +308,178 @@ export function useGraphInteractions( const availableWidth = Math.max(1, viewportWidth - occludedRightPx); // Calculate the zoom needed to fit the content within available width - const zoomX = availableWidth / paddedWidth; - const zoomY = viewportHeight / paddedHeight; - const newZoom = Math.min(Math.max(0.1, Math.min(zoomX, zoomY)), 2); + const zoomX = availableWidth / paddedWidth + const zoomY = viewportHeight / paddedHeight + const newZoom = Math.min(Math.max(0.05, Math.min(zoomX, zoomY)), 3) // Calculate pan to center the content within available area - const availableCenterX = availableWidth / 2; - const newPanX = availableCenterX - contentCenterX * newZoom; - const newPanY = viewportHeight / 2 - contentCenterY * newZoom; + const availableCenterX = availableWidth / 2 + const newPanX = availableCenterX - contentCenterX * newZoom + const newPanY = viewportHeight / 2 - contentCenterY * newZoom // Apply the new view (optional animation) if (options?.animate) { - const steps = 8; - const durationMs = 160; // snappy - const intervalMs = Math.max(1, Math.floor(durationMs / steps)); - const startZoom = zoom; - const startPanX = panX; - const startPanY = panY; - let i = 0; - const ease = (t: number) => 1 - (1 - t) ** 2; // ease-out quad + const steps = 8 + const durationMs = 160 // snappy + const intervalMs = Math.max(1, Math.floor(durationMs / steps)) + const startZoom = zoom + const startPanX = panX + const startPanY = panY + let i = 0 + const ease = (t: number) => 1 - (1 - t) ** 2 // ease-out quad const timer = setInterval(() => { - i++; - const t = ease(i / steps); - setZoom(startZoom + (newZoom - startZoom) * t); - setPanX(startPanX + (newPanX - startPanX) * t); - setPanY(startPanY + (newPanY - startPanY) * t); - if (i >= steps) clearInterval(timer); - }, intervalMs); + i++ + const t = ease(i / steps) + setZoom(startZoom + (newZoom - startZoom) * t) + setPanX(startPanX + (newPanX - startPanX) * t) + setPanY(startPanY + (newPanY - startPanY) * t) + if (i >= steps) clearInterval(timer) + }, intervalMs) } else { - setZoom(newZoom); - setPanX(newPanX); - setPanY(newPanY); + setZoom(newZoom) + setPanX(newPanX) + setPanY(newPanY) } }, [zoom, panX, panY], - ); + ) + + // Touch gesture handlers for mobile pinch-to-zoom + const handleTouchStart = useCallback((e: React.TouchEvent) => { + const touches = Array.from(e.touches).map(touch => ({ + id: touch.identifier, + x: touch.clientX, + y: touch.clientY, + })) + + if (touches.length >= 2) { + // Start gesture with two or more fingers + const touch1 = touches[0]! + const touch2 = touches[1]! + + const distance = Math.sqrt( + Math.pow(touch2.x - touch1.x, 2) + Math.pow(touch2.y - touch1.y, 2) + ) + + const center = { + x: (touch1.x + touch2.x) / 2, + y: (touch1.y + touch2.y) / 2, + } + + setTouchState({ + touches, + lastDistance: distance, + lastCenter: center, + isGesturing: true, + }) + } else { + setTouchState(prev => ({ ...prev, touches, isGesturing: false })) + } + }, []) + + const handleTouchMove = useCallback((e: React.TouchEvent) => { + e.preventDefault() + + const touches = Array.from(e.touches).map(touch => ({ + id: touch.identifier, + x: touch.clientX, + y: touch.clientY, + })) + + if (touches.length >= 2 && touchState.isGesturing) { + const touch1 = touches[0]! + const touch2 = touches[1]! + + const distance = Math.sqrt( + Math.pow(touch2.x - touch1.x, 2) + Math.pow(touch2.y - touch1.y, 2) + ) + + const center = { + x: (touch1.x + touch2.x) / 2, + y: (touch1.y + touch2.y) / 2, + } + + // Calculate zoom change based on pinch distance change + const distanceChange = distance / touchState.lastDistance + const newZoom = Math.max(0.05, Math.min(3, zoom * distanceChange)) + + // Get canvas bounds for center calculation + const canvas = e.currentTarget as HTMLElement + const rect = canvas.getBoundingClientRect() + const centerX = center.x - rect.left + const centerY = center.y - rect.top + + // Calculate the world position of the pinch center + const worldX = (centerX - panX) / zoom + const worldY = (centerY - panY) / zoom + + // Calculate new pan to keep the pinch center stationary + const newPanX = centerX - worldX * newZoom + const newPanY = centerY - worldY * newZoom + + // Calculate pan change based on center movement + const centerDx = center.x - touchState.lastCenter.x + const centerDy = center.y - touchState.lastCenter.y + + setZoom(newZoom) + setPanX(newPanX + centerDx) + setPanY(newPanY + centerDy) + + setTouchState({ + touches, + lastDistance: distance, + lastCenter: center, + isGesturing: true, + }) + } else if (touches.length === 1 && !touchState.isGesturing && isPanning) { + // Single finger pan (only if not in gesture mode) + const touch = touches[0]! + const newPanX = touch.x - panStart.x + const newPanY = touch.y - panStart.y + setPanX(newPanX) + setPanY(newPanY) + } + }, [touchState, zoom, panX, panY, isPanning, panStart]) + + const handleTouchEnd = useCallback((e: React.TouchEvent) => { + const touches = Array.from(e.touches).map(touch => ({ + id: touch.identifier, + x: touch.clientX, + y: touch.clientY, + })) + + if (touches.length < 2) { + setTouchState(prev => ({ ...prev, touches, isGesturing: false })) + } else { + setTouchState(prev => ({ ...prev, touches })) + } + + if (touches.length === 0) { + setIsPanning(false) + } + }, []) + + // Center viewport on a specific world position (with animation) + const centerViewportOn = useCallback( + ( + worldX: number, + worldY: number, + viewportWidth: number, + viewportHeight: number, + animate: boolean = true + ) => { + const newPanX = viewportWidth / 2 - worldX * zoom + const newPanY = viewportHeight / 2 - worldY * zoom + + if (animate && !isAnimating) { + animateToViewState(newPanX, newPanY, zoom, 400) + } else { + setPanX(newPanX) + setPanY(newPanY) + } + }, + [zoom, isAnimating, animateToViewState], + ) // Node interaction handlers const handleNodeHover = useCallback((nodeId: string | null) => { @@ -203,29 +495,36 @@ export function useGraphInteractions( const handleDoubleClick = useCallback( (e: React.MouseEvent) => { - const canvas = e.currentTarget as HTMLCanvasElement; - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - // Calculate new zoom (zoom in by 1.5x) - const zoomFactor = 1.5; - const newZoom = Math.min(2, zoom * zoomFactor); + const zoomFactor = 1.5 + const newZoom = Math.min(3, zoom * zoomFactor) + + // Get mouse position relative to the container + let mouseX = e.clientX + let mouseY = e.clientY + + // Try to get the container bounds to make coordinates relative to the graph container + const target = e.currentTarget + if (target && 'getBoundingClientRect' in target) { + const rect = target.getBoundingClientRect() + mouseX = e.clientX - rect.left + mouseY = e.clientY - rect.top + } // Calculate the world position of the clicked point - const worldX = (x - panX) / zoom; - const worldY = (y - panY) / zoom; + const worldX = (mouseX - panX) / zoom + const worldY = (mouseY - panY) / zoom // Calculate new pan to keep the clicked point in the same screen position - const newPanX = x - worldX * newZoom; - const newPanY = y - worldY * newZoom; + const newPanX = mouseX - worldX * newZoom + const newPanY = mouseY - worldY * newZoom - setZoom(newZoom); - setPanX(newPanX); - setPanY(newPanY); + setZoom(newZoom) + setPanX(newPanX) + setPanY(newPanY) }, [zoom, panX, panY], - ); + ) return { // State @@ -247,11 +546,16 @@ export function useGraphInteractions( handleNodeDragMove, handleNodeDragEnd, handleDoubleClick, + // Touch handlers + handleTouchStart, + handleTouchMove, + handleTouchEnd, // Controls zoomIn, zoomOut, resetView, autoFitToViewport, + centerViewportOn, setSelectedNode, - }; + } } diff --git a/packages/ui/memory-graph/memory-graph.tsx b/packages/ui/memory-graph/memory-graph.tsx index 75ada513..912a741a 100644 --- a/packages/ui/memory-graph/memory-graph.tsx +++ b/packages/ui/memory-graph/memory-graph.tsx @@ -9,6 +9,7 @@ import { useGraphData } from "./hooks/use-graph-data"; import { useGraphInteractions } from "./hooks/use-graph-interactions"; import { Legend } from "./legend"; import { LoadingIndicator } from "./loading-indicator"; +import { NavigationControls } from "./navigation-controls"; import { NodeDetailPanel } from "./node-detail-panel"; import { SpacesDropdown } from "./spaces-dropdown"; @@ -71,8 +72,14 @@ export const MemoryGraph = ({ handleNodeDragMove, handleNodeDragEnd, handleDoubleClick, + handleTouchStart, + handleTouchMove, + handleTouchEnd, setSelectedNode, autoFitToViewport, + centerViewportOn, + zoomIn, + zoomOut, } = useGraphInteractions(variant); // Graph data @@ -188,6 +195,37 @@ export const MemoryGraph = ({ [handleNodeDragStart, nodes], ); + // Navigation callbacks + const handleCenter = useCallback(() => { + if (nodes.length > 0) { + // Calculate center of all nodes + let sumX = 0 + let sumY = 0 + let count = 0 + + nodes.forEach((node) => { + sumX += node.x + sumY += node.y + count++ + }) + + if (count > 0) { + const centerX = sumX / count + const centerY = sumY / count + centerViewportOn(centerX, centerY, containerSize.width, containerSize.height) + } + } + }, [nodes, centerViewportOn, containerSize.width, containerSize.height]) + + const handleAutoFit = useCallback(() => { + if (nodes.length > 0 && containerSize.width > 0 && containerSize.height > 0) { + autoFitToViewport(nodes, containerSize.width, containerSize.height, { + occludedRightPx, + animate: true, + }) + } + }, [nodes, containerSize.width, containerSize.height, occludedRightPx, autoFitToViewport]) + // Get selected node data const selectedNodeData = useMemo(() => { if (!selectedNode) return null; @@ -368,6 +406,9 @@ export const MemoryGraph = ({ onPanEnd={handlePanEnd} onPanMove={handlePanMove} onPanStart={handlePanStart} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} onWheel={handleWheel} panX={panX} panY={panY} @@ -375,6 +416,18 @@ export const MemoryGraph = ({ zoom={zoom} /> )} + + {/* Navigation controls */} + {containerSize.width > 0 && ( + zoomIn(containerSize.width / 2, containerSize.height / 2)} + onZoomOut={() => zoomOut(containerSize.width / 2, containerSize.height / 2)} + onAutoFit={handleAutoFit} + nodes={nodes} + className="absolute bottom-4 left-4" + /> + )}
); diff --git a/packages/ui/memory-graph/navigation-controls.tsx b/packages/ui/memory-graph/navigation-controls.tsx new file mode 100644 index 00000000..b2abd67f --- /dev/null +++ b/packages/ui/memory-graph/navigation-controls.tsx @@ -0,0 +1,67 @@ +"use client" + +import { memo } from "react" +import type { GraphNode } from "./types" + +interface NavigationControlsProps { + onCenter: () => void + onZoomIn: () => void + onZoomOut: () => void + onAutoFit: () => void + nodes: GraphNode[] + className?: string +} + +export const NavigationControls = memo(({ + onCenter, + onZoomIn, + onZoomOut, + onAutoFit, + nodes, + className = "", +}) => { + if (nodes.length === 0) { + return null + } + + return ( +
+ + +
+ + +
+
+ ) +}) + +NavigationControls.displayName = "NavigationControls" \ No newline at end of file diff --git a/packages/ui/memory-graph/types.ts b/packages/ui/memory-graph/types.ts index f1af3ac2..4692d2c0 100644 --- a/packages/ui/memory-graph/types.ts +++ b/packages/ui/memory-graph/types.ts @@ -68,6 +68,9 @@ export interface GraphCanvasProps { onPanEnd: () => void; onWheel: (e: React.WheelEvent) => void; onDoubleClick: (e: React.MouseEvent) => void; + onTouchStart?: (e: React.TouchEvent) => void; + onTouchMove?: (e: React.TouchEvent) => void; + onTouchEnd?: (e: React.TouchEvent) => void; draggingNodeId: string | null; // Optional list of document IDs (customId or internal id) to highlight highlightDocumentIds?: string[]; From 12223f6fdecae7a36e55f4009677a0772ba7cc58 Mon Sep 17 00:00:00 2001 From: Dhravya Shah Date: Fri, 29 Aug 2025 17:18:02 -0700 Subject: [PATCH 5/5] fix: build --- packages/ui/memory-graph/graph-webgl-canvas.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/memory-graph/graph-webgl-canvas.tsx b/packages/ui/memory-graph/graph-webgl-canvas.tsx index d45e75c8..480d1d6b 100644 --- a/packages/ui/memory-graph/graph-webgl-canvas.tsx +++ b/packages/ui/memory-graph/graph-webgl-canvas.tsx @@ -697,6 +697,7 @@ export const GraphWebGLCanvas = memo( const { dx, dy } = pendingWheelDeltaRef.current; pendingWheelDeltaRef.current = { dx: 0, dy: 0 }; + // @ts-expect-error onWheel({ deltaY: dy, deltaX: dx,