Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions packages/web/src/components/CreateGraphModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useGraph } from '../contexts/GraphContext';
import { useAuth } from '../contexts/AuthContext';
import { useNotifications } from '../contexts/NotificationContext';
import { CreateGraphInput } from '../types/graph';
import { createPortal } from 'react-dom';

interface CreateGraphModalProps {
isOpen: boolean;
Expand Down Expand Up @@ -212,7 +213,9 @@ export function CreateGraphModal({ isOpen, onClose, parentGraphId }: CreateGraph

if (!isOpen) return null;

return (
// In document.body: inside the layout's content wrapper the phone's top bar
// and bottom nav (z-30, outside it) would draw over the dialog and take its taps.
return createPortal((
<div data-modal-backdrop className="fixed inset-0 z-[9999] overflow-y-auto backdrop-blur-sm" style={{position: 'fixed', top: 0, left: 0, right: 0, bottom: 0}}>
<div className="modal-stage">
{/* Enhanced Backdrop with gradient */}
Expand Down Expand Up @@ -773,5 +776,5 @@ export function CreateGraphModal({ isOpen, onClose, parentGraphId }: CreateGraph
</div>
</div>
</div>
);
), document.body);
}
14 changes: 10 additions & 4 deletions packages/web/src/components/DeleteGraphModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { useQuery, useMutation } from '@apollo/client';
import { X, Trash2, GitBranch, Shield } from 'lucide-react';
import { AlertTriangle, CheckCircle, WORK_ITEM_TYPES } from '../constants/workItemConstants';
Expand All @@ -8,6 +8,8 @@ import { GET_WORK_ITEMS, GET_EDGES, DELETE_WORK_ITEM, DELETE_EDGE } from '../lib
import { GET_CHILD_GRAPHS } from '../graphql/graph';
import { projectDeleteBlock } from '../lib/workspaceRules';
import type { Graph } from '../types/graph';
import { createPortal } from 'react-dom';
import { useModalA11y } from '../hooks/useModalA11y';

interface DeleteGraphModalProps {
isOpen: boolean;
Expand All @@ -17,6 +19,8 @@ interface DeleteGraphModalProps {
}

export function DeleteGraphModal({ isOpen, onClose, graph }: DeleteGraphModalProps) {
const panelRef = useRef<HTMLDivElement>(null);
useModalA11y(panelRef, { isOpen, label: 'Delete graph' });
const { currentGraph, deleteGraph } = useGraph();
const targetGraph = graph ?? currentGraph;
// Asked of the backend each time: another tab may have added a graph inside since this tab's list was read.
Expand Down Expand Up @@ -239,7 +243,9 @@ export function DeleteGraphModal({ isOpen, onClose, graph }: DeleteGraphModalPro
}
};

return (
// In document.body: inside the layout's content wrapper the phone's top bar
// and bottom nav (z-30, outside it) would draw over the dialog and take its taps.
return createPortal((
<div className="fixed inset-0 z-[9999] overflow-y-auto backdrop-blur-sm">
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
{/* Enhanced Backdrop with gradient */}
Expand All @@ -249,7 +255,7 @@ export function DeleteGraphModal({ isOpen, onClose, graph }: DeleteGraphModalPro
/>

{/* Enhanced Modal with better styling */}
<div className="inline-block align-bottom bg-gradient-to-br from-gray-800 via-gray-800 to-gray-900 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full border border-red-600/30 animate-in slide-in-from-bottom-4 duration-300 relative z-10">
<div ref={panelRef} data-modal-panel className="inline-block align-bottom bg-gradient-to-br from-gray-800 via-gray-800 to-gray-900 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full border border-red-600/30 animate-in slide-in-from-bottom-4 duration-300 relative z-10">
{/* Gradient accent line at top - red for danger */}
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-red-500 via-orange-500 via-pink-500 to-red-600"></div>

Expand Down Expand Up @@ -807,5 +813,5 @@ export function DeleteGraphModal({ isOpen, onClose, graph }: DeleteGraphModalPro
</div>
</div>
</div>
);
), document.body);
}
52 changes: 50 additions & 2 deletions packages/web/src/components/GraphSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { GraphSelector } from './GraphSelector';
import { onGraphRequests, requestGraph } from '../lib/graphRequests';

const graph = (id: string, name: string, extra: Record<string, unknown> = {}) => ({
id, name, type: 'SUBGRAPH', createdBy: 'me', parentGraphId: null, depth: 0, path: [id], nodeCount: 1, edgeCount: 0, isShared: false, ...extra,
Expand Down Expand Up @@ -58,7 +59,8 @@ describe('the graph selector edit and delete buttons', () => {
const { onDeleteGraph, actions } = open();
const own = actions('Welcome', 0);
fireEvent.mouseEnter(own.trash);
expect(screen.queryByText(/System graph/)).toBeNull();
expect(screen.queryByRole('tooltip')).toBeNull();
expect(own.trash.getAttribute('aria-describedby')).toBeNull();
fireEvent.click(own.trash);
expect(onDeleteGraph).toHaveBeenCalledWith(expect.objectContaining({ id: 'w', createdBy: 'me' }));
});
Expand All @@ -67,8 +69,54 @@ describe('the graph selector edit and delete buttons', () => {
const { onDeleteGraph, actions } = open();
const shared = actions('Welcome', 1);
fireEvent.mouseEnter(shared.trash);
expect(screen.getByText("🔒 System graph — can't be deleted")).toBeTruthy();
expect(screen.getByRole('tooltip').textContent).toBe("🔒 System graph — can't be deleted");
fireEvent.click(shared.trash);
expect(onDeleteGraph).not.toHaveBeenCalled();
});

it('explain a locked button when it is tapped, on screen and to screen readers', () => {
const { actions } = open();
const shared = actions('Welcome', 1);
fireEvent.click(shared.trash);
expect(screen.getByRole('tooltip').textContent).toBe("🔒 System graph — can't be deleted");
expect(document.getElementById(shared.trash.getAttribute('aria-describedby')!)?.textContent).toBe("🔒 System graph — can't be deleted");
expect(shared.trash.getAttribute('aria-disabled')).toBe('true');
});

it('put the reason on the left of a button near the right edge, as on a phone', () => {
const { actions } = open();
const shared = actions('Welcome', 1);
shared.trash.getBoundingClientRect = () => ({ left: window.innerWidth - 40, right: window.innerWidth - 10, top: 100, height: 30, width: 30, bottom: 130, x: 0, y: 0, toJSON: () => ({}) });
fireEvent.click(shared.trash);
const tip = screen.getByRole('tooltip');
expect(tip.className).toContain('-translate-x-full');
expect(parseFloat(tip.style.left)).toBe(window.innerWidth - 48);
});
});

describe('the phone top bar graph selector', () => {
it('asks the workspace to create, rename and delete a graph, by name', () => {
const answer = { create: vi.fn(), edit: vi.fn(), delete: vi.fn() };
const stop = onGraphRequests(answer);
render(
<GraphSelector
testId="graph-selector-mobile"
onCreateGraph={() => requestGraph('create')}
onEditGraph={graph => requestGraph('edit', graph)}
onDeleteGraph={graph => requestGraph('delete', graph)}
/>
);
const list = () => {
fireEvent.click(screen.getByTestId('graph-selector-mobile'));
return within(screen.getByTestId('graph-selector-dropdown'));
};
fireEvent.click(list().getByRole('button', { name: 'Edit Ideas' }));
fireEvent.click(list().getByRole('button', { name: 'Delete Ideas' }));
fireEvent.click(list().getByTitle('Create New Graph'));
stop();

expect(answer.edit).toHaveBeenCalledWith(expect.objectContaining({ id: 'i', name: 'Ideas' }));
expect(answer.delete).toHaveBeenCalledWith(expect.objectContaining({ id: 'i', name: 'Ideas' }));
expect(answer.create).toHaveBeenCalledTimes(1);
});
});
73 changes: 47 additions & 26 deletions packages/web/src/components/GraphSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import { GET_WORK_ITEMS, GET_EDGES } from '../lib/queries';
import { offlineEnabled, localGuest } from '../lib/offline/engine';
import { exampleAction, starterHierarchy, systemLocked } from '../lib/workspaceRules';

/** Room the locked-button reason needs beside its button (it is one line, about 200 px). */
const LOCKED_TIP_WIDTH = 240;

interface GraphSelectorProps {
onCreateGraph?: () => void;
onEditGraph?: (graph: any) => void;
Expand Down Expand Up @@ -55,7 +58,11 @@ export function GraphSelector({ onCreateGraph, onEditGraph, onDeleteGraph, testI
});
};
const [buttonPosition, setButtonPosition] = useState<{ top: number; left: number; width: number } | null>(null);
const [hoveredTooltip, setHoveredTooltip] = useState<{ message: string; x: number; y: number } | null>(null);
const [hoveredTooltip, setHoveredTooltip] = useState<{ message: string; x: number; y: number; left?: boolean } | null>(null);
const tooltipTimer = useRef<number | null>(null);
useEffect(() => () => {
if (tooltipTimer.current !== null) window.clearTimeout(tooltipTimer.current);
}, []);
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);

Expand Down Expand Up @@ -155,31 +162,44 @@ export function GraphSelector({ onCreateGraph, onEditGraph, onDeleteGraph, testI
const graphActions = (row: any) => {
const graph = availableGraphs.find(g => g.id === row.id) ?? row;
const isSystemGraph = systemLocked(graph, currentUser?.id);
const action = (run: (graph: any) => void, locked: string, hover: string, icon: ReactNode) => (
<button
onClick={(e) => {
e.stopPropagation();
if (!isSystemGraph) {
run(graph);
setIsOpen(false);
}
}}
onMouseEnter={(e) => {
if (isSystemGraph) {
const rect = e.currentTarget.getBoundingClientRect();
setHoveredTooltip({ message: locked, x: rect.right + 8, y: rect.top + rect.height / 2 });
}
}}
onMouseLeave={() => setHoveredTooltip(null)}
className={`p-1.5 rounded-lg transition-all duration-200 ${isSystemGraph ? 'text-gray-600 cursor-not-allowed' : `text-gray-400 ${hover} hover:scale-110`}`}
>
{icon}
</button>
);
// Why a locked button does nothing: beside it (on its left when the right
// edge is near, as on a phone), on hover or on a tap, and to screen readers.
const explain = (target: HTMLElement, message: string, fade: boolean) => {
const rect = target.getBoundingClientRect();
const left = rect.right + 8 + LOCKED_TIP_WIDTH > window.innerWidth;
if (tooltipTimer.current !== null) window.clearTimeout(tooltipTimer.current);
tooltipTimer.current = fade ? window.setTimeout(() => setHoveredTooltip(null), 2500) : null;
setHoveredTooltip({ message, x: left ? rect.left - 8 : rect.right + 8, y: rect.top + rect.height / 2, left });
};
const action = (label: string, run: (graph: any) => void, locked: string, hover: string, icon: ReactNode) => {
const reasonId = `graph-locked-${label.toLowerCase()}-${graph.id}`;
return (
<button
aria-label={`${label} ${graph.name}`}
aria-disabled={isSystemGraph || undefined}
aria-describedby={isSystemGraph ? reasonId : undefined}
onClick={(e) => {
e.stopPropagation();
if (!isSystemGraph) {
run(graph);
setIsOpen(false);
} else explain(e.currentTarget, locked, true);
}}
onMouseEnter={(e) => {
if (isSystemGraph) explain(e.currentTarget, locked, false);
}}
onMouseLeave={() => setHoveredTooltip(null)}
className={`p-1.5 [@media(hover:none)]:p-[15px] rounded-lg transition-all duration-200 ${isSystemGraph ? 'text-gray-600 cursor-not-allowed' : `text-gray-400 ${hover} hover:scale-110`}`}
>
{icon}
{isSystemGraph && <span id={reasonId} className="sr-only">{locked}</span>}
</button>
);
};
return (
<div className="shrink-0 flex items-center space-x-1 opacity-0 group-hover/item:opacity-100 transition-opacity ml-2">
{onEditGraph && action(onEditGraph, '🔒 System graph — can\'t be edited', 'hover:text-blue-400 hover:bg-blue-500/20', <Edit3 className="h-3.5 w-3.5" />)}
{onDeleteGraph && action(onDeleteGraph, '🔒 System graph — can\'t be deleted', 'hover:text-red-400 hover:bg-red-500/20', <Trash2 className="h-3.5 w-3.5" />)}
<div className="shrink-0 flex items-center space-x-1 opacity-0 group-hover/item:opacity-100 [@media(hover:none)]:opacity-100 transition-opacity ml-2">
{onEditGraph && action('Edit', onEditGraph, '🔒 System graph — can\'t be edited', 'hover:text-blue-400 hover:bg-blue-500/20', <Edit3 className="h-3.5 w-3.5" />)}
{onDeleteGraph && action('Delete', onDeleteGraph, '🔒 System graph — can\'t be deleted', 'hover:text-red-400 hover:bg-red-500/20', <Trash2 className="h-3.5 w-3.5" />)}
</div>
);
};
Expand Down Expand Up @@ -519,7 +539,8 @@ export function GraphSelector({ onCreateGraph, onEditGraph, onDeleteGraph, testI
{/* Tooltip Portal */}
{hoveredTooltip && createPortal(
<div
className="fixed px-2 py-1 bg-red-600 text-white text-xs rounded whitespace-nowrap pointer-events-none z-[9999999999] -translate-y-1/2"
role="tooltip"
className={`fixed px-2 py-1 bg-red-600 text-white text-xs rounded whitespace-nowrap pointer-events-none z-[9999999999] -translate-y-1/2 ${hoveredTooltip.left ? '-translate-x-full' : ''}`}
style={{
left: hoveredTooltip.x,
top: hoveredTooltip.y
Expand Down
8 changes: 7 additions & 1 deletion packages/web/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Link, useLocation } from 'react-router-dom';
import { Brain, Bot, BarChart3, Settings, Server, Globe, Shield, Users, Terminal, Plus } from 'lucide-react';
import { UserSelector } from './UserSelector';
import { GraphSelector } from './GraphSelector';
import { requestGraph } from '../lib/graphRequests';
import { useAuth } from '../contexts/AuthContext';
import { canAccessAdmin, canAccessBackend } from '../lib/roleAccess';
import { McpHealthIndicator } from './McpHealthIndicator';
Expand Down Expand Up @@ -62,7 +63,12 @@ export function Layout({ children }: LayoutProps) {
<div data-workspace-header className="mobile-project-header md:hidden relative z-30 pt-safe">
<div className="flex items-center bg-gray-800/90 backdrop-blur-sm px-3 py-2 border-b border-gray-700/50">
<div className="flex-1 min-w-0">
<GraphSelector testId="graph-selector-mobile" />
<GraphSelector
testId="graph-selector-mobile"
onCreateGraph={() => requestGraph('create')}
onEditGraph={graph => requestGraph('edit', graph)}
onDeleteGraph={graph => requestGraph('delete', graph)}
/>
</div>
<button aria-label="New work item" onClick={()=>window.dispatchEvent(new Event('graphdone-create-task'))} className="min-h-[44px] min-w-[44px] ml-2 flex items-center justify-center rounded-lg bg-violet-700 text-white"><Plus className="h-6 w-6" aria-hidden="true" /></button>
</div>
Expand Down
14 changes: 10 additions & 4 deletions packages/web/src/components/UpdateGraphModal.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { X, Folder, FolderOpen, Plus, FileText, Save } from 'lucide-react';
import { useGraph } from '../contexts/GraphContext';
import { useAuth } from '../contexts/AuthContext';
import { useNotifications } from '../contexts/NotificationContext';
import { createPortal } from 'react-dom';
import { useModalA11y } from '../hooks/useModalA11y';

interface UpdateGraphModalProps {
isOpen: boolean;
Expand All @@ -11,6 +13,8 @@ interface UpdateGraphModalProps {
}

export function UpdateGraphModal({ isOpen, onClose, graphToEdit }: UpdateGraphModalProps) {
const panelRef = useRef<HTMLDivElement>(null);
useModalA11y(panelRef, { isOpen, label: 'Edit graph' });
const { updateGraph, availableGraphs } = useGraph();
const { currentTeam } = useAuth();
const { showSuccess, showError } = useNotifications();
Expand Down Expand Up @@ -189,7 +193,9 @@ export function UpdateGraphModal({ isOpen, onClose, graphToEdit }: UpdateGraphMo
}
};

return (
// In document.body: inside the layout's content wrapper the phone's top bar
// and bottom nav (z-30, outside it) would draw over the dialog and take its taps.
return createPortal((
<div className="fixed inset-0 z-[9999] overflow-y-auto backdrop-blur-sm" style={{position: 'fixed', top: 0, left: 0, right: 0, bottom: 0}}>
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
{/* Enhanced Backdrop with gradient */}
Expand All @@ -199,7 +205,7 @@ export function UpdateGraphModal({ isOpen, onClose, graphToEdit }: UpdateGraphMo
/>

{/* Enhanced Modal with better styling */}
<div className="inline-block align-bottom bg-gradient-to-br from-gray-800 via-gray-800 to-gray-900 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full border border-gray-600/50 animate-in slide-in-from-bottom-4 duration-300 relative">
<div ref={panelRef} data-modal-panel className="inline-block align-bottom bg-gradient-to-br from-gray-800 via-gray-800 to-gray-900 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full border border-gray-600/50 animate-in slide-in-from-bottom-4 duration-300 relative">
{/* Gradient accent line at top */}
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-blue-500 via-purple-500 via-pink-500 to-green-500"></div>

Expand Down Expand Up @@ -485,5 +491,5 @@ export function UpdateGraphModal({ isOpen, onClose, graphToEdit }: UpdateGraphMo
</div>
</div>
</div>
);
), document.body);
}
10 changes: 6 additions & 4 deletions packages/web/src/hooks/useModalA11y.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,13 @@ export function useModalA11y(ref: RefObject<HTMLElement>, opts: ModalA11yOptions
const position = () => {
const bottom = Math.max(0, ...headers.filter(header => header.getClientRects().length > 0)
.map(header => header.getBoundingClientRect().bottom));
const offset = !matchMedia(COMPACT_WORKSPACE_QUERY).matches && window.innerHeight - bottom >= 420 ? bottom : 0;
const compact = typeof window.matchMedia === 'function' && window.matchMedia(COMPACT_WORKSPACE_QUERY).matches;
const offset = !compact && window.innerHeight - bottom >= 420 ? bottom : 0;
backdrop?.style.setProperty('--modal-header-offset', `${offset}px`);
};
const observer = new ResizeObserver(position);
headers.forEach(header => observer.observe(header));
// Without ResizeObserver (older browsers, jsdom) the window resize below still positions it.
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(position);
headers.forEach(header => observer?.observe(header));
window.addEventListener('resize', position);
position();

Expand Down Expand Up @@ -124,7 +126,7 @@ export function useModalA11y(ref: RefObject<HTMLElement>, opts: ModalA11yOptions
el.addEventListener('keydown', onKeyDown);

return () => {
observer.disconnect();
observer?.disconnect();
window.removeEventListener('resize', position);
backdrop?.style.removeProperty('--modal-header-offset');
if (raf) cancelAnimationFrame(raf);
Expand Down
Loading
Loading