Make memory and context views mobile-friendly (#813)

* Changes from fix/memory-and-context-mobile-friendly

* fix: Improve file extension detection and add path traversal protection

* refactor: Extract file extension utilities and add path traversal guards

Code review improvements:
- Extract isMarkdownFilename and isImageFilename to shared image-utils.ts
- Remove duplicated code from context-view.tsx and memory-view.tsx
- Add path traversal guard for context fixture utilities (matching memory)
- Add 7 new tests for context fixture path traversal protection
- Total 61 tests pass

Addresses code review feedback from PR #813

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: Add e2e tests for profiles crud and board background persistence

* Update apps/ui/playwright.config.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Add robust test navigation handling and file filtering

* fix: Format NODE_OPTIONS configuration on single line

* test: Update profiles and board background persistence tests

* test: Replace iPhone 13 Pro with Pixel 5 for mobile test consistency

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
gsxdsm
2026-02-26 03:31:40 -08:00
committed by GitHub
parent 6408f514a4
commit 583c3eb4a6
30 changed files with 3758 additions and 113 deletions

View File

@@ -24,8 +24,10 @@ import {
FilePlus,
FileUp,
MoreVertical,
ArrowLeft,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { useIsMobile } from '@/hooks/use-media-query';
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
@@ -42,7 +44,7 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import { sanitizeFilename } from '@/lib/image-utils';
import { sanitizeFilename, isMarkdownFilename, isImageFilename } from '@/lib/image-utils';
import { Markdown } from '../ui/markdown';
import {
DropdownMenu,
@@ -54,6 +56,16 @@ import { Textarea } from '@/components/ui/textarea';
const logger = createLogger('ContextView');
// Responsive layout classes
const FILE_LIST_BASE_CLASSES = 'border-r border-border flex flex-col overflow-hidden';
const FILE_LIST_DESKTOP_CLASSES = 'w-64';
const FILE_LIST_EXPANDED_CLASSES = 'flex-1';
const FILE_LIST_MOBILE_NO_SELECTION_CLASSES = 'w-full border-r-0';
const FILE_LIST_MOBILE_SELECTION_CLASSES = 'hidden';
const EDITOR_PANEL_BASE_CLASSES = 'flex-1 flex flex-col overflow-hidden';
const EDITOR_PANEL_MOBILE_HIDDEN_CLASSES = 'hidden';
interface ContextFile {
name: string;
type: 'text' | 'image';
@@ -103,6 +115,9 @@ export function ContextView() {
// File input ref for import
const fileInputRef = useRef<HTMLInputElement>(null);
// Mobile detection
const isMobile = useIsMobile();
// Keyboard shortcuts for this view
const contextShortcuts: KeyboardShortcut[] = useMemo(
() => [
@@ -122,18 +137,6 @@ export function ContextView() {
return `${currentProject.path}/.automaker/context`;
}, [currentProject]);
const isMarkdownFile = (filename: string): boolean => {
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
return ext === '.md' || ext === '.markdown';
};
// Determine if a file is an image based on extension
const isImageFile = (filename: string): boolean => {
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp'];
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
return imageExtensions.includes(ext);
};
// Load context metadata
const loadMetadata = useCallback(async (): Promise<ContextMetadata> => {
const contextPath = getContextPath();
@@ -195,10 +198,15 @@ export function ContextView() {
const result = await api.readdir(contextPath);
if (result.success && result.entries) {
const files: ContextFile[] = result.entries
.filter((entry) => entry.isFile && entry.name !== 'context-metadata.json')
.filter(
(entry) =>
entry.isFile &&
entry.name !== 'context-metadata.json' &&
(isMarkdownFilename(entry.name) || isImageFilename(entry.name))
)
.map((entry) => ({
name: entry.name,
type: isImageFile(entry.name) ? 'image' : 'text',
type: isImageFilename(entry.name) ? 'image' : 'text',
path: `${contextPath}/${entry.name}`,
description: metadata.files[entry.name]?.description,
}));
@@ -232,11 +240,10 @@ export function ContextView() {
// Select a file
const handleSelectFile = (file: ContextFile) => {
if (hasChanges) {
// Could add a confirmation dialog here
}
// Note: Unsaved changes warning could be added here in the future
// For now, silently proceed to avoid disrupting mobile UX flow
loadFileContent(file);
setIsPreviewMode(isMarkdownFile(file.name));
setIsPreviewMode(isMarkdownFilename(file.name));
};
// Save current file
@@ -341,7 +348,7 @@ export function ContextView() {
try {
const api = getElectronAPI();
const isImage = isImageFile(file.name);
const isImage = isImageFilename(file.name);
let filePath: string;
let fileName: string;
@@ -582,7 +589,7 @@ export function ContextView() {
// Update selected file with new name and path
const renamedFile: ContextFile = {
name: newName,
type: isImageFile(newName) ? 'image' : 'text',
type: isImageFilename(newName) ? 'image' : 'text',
path: newPath,
content: result.content,
description: metadata.files[newName]?.description,
@@ -790,7 +797,17 @@ export function ContextView() {
)}
{/* Left Panel - File List */}
<div className="w-64 border-r border-border flex flex-col overflow-hidden">
{/* Mobile: Full width, hidden when file is selected (full-screen editor) */}
{/* Desktop: Fixed width w-64, expands to fill space when no file selected */}
<div
className={cn(
FILE_LIST_BASE_CLASSES,
FILE_LIST_DESKTOP_CLASSES,
!selectedFile && FILE_LIST_EXPANDED_CLASSES,
isMobile && !selectedFile && FILE_LIST_MOBILE_NO_SELECTION_CLASSES,
isMobile && selectedFile && FILE_LIST_MOBILE_SELECTION_CLASSES
)}
>
<div className="p-3 border-b border-border">
<h2 className="text-sm font-semibold text-muted-foreground">
Context Files ({contextFiles.length})
@@ -881,12 +898,31 @@ export function ContextView() {
</div>
{/* Right Panel - Editor/Preview */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Mobile: Hidden when no file selected (file list shows full screen) */}
<div
className={cn(
EDITOR_PANEL_BASE_CLASSES,
isMobile && !selectedFile && EDITOR_PANEL_MOBILE_HIDDEN_CLASSES
)}
>
{selectedFile ? (
<>
{/* File toolbar */}
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
<div className="flex items-center gap-2 min-w-0">
{/* Mobile-only: Back button to return to file list */}
{isMobile && (
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedFile(null)}
className="shrink-0 -ml-1"
aria-label="Back"
title="Back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
)}
{selectedFile.type === 'image' ? (
<ImageIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
) : (
@@ -894,23 +930,26 @@ export function ContextView() {
)}
<span className="text-sm font-medium truncate">{selectedFile.name}</span>
</div>
<div className="flex gap-2">
{selectedFile.type === 'text' && isMarkdownFile(selectedFile.name) && (
<div className={cn('flex gap-2', isMobile && 'gap-1')}>
{/* Mobile: Icon-only buttons with aria-labels for accessibility */}
{selectedFile.type === 'text' && isMarkdownFilename(selectedFile.name) && (
<Button
variant={'outline'}
size="sm"
onClick={() => setIsPreviewMode(!isPreviewMode)}
data-testid="toggle-preview-mode"
aria-label={isPreviewMode ? 'Edit' : 'Preview'}
title={isPreviewMode ? 'Edit' : 'Preview'}
>
{isPreviewMode ? (
<>
<Pencil className="w-4 h-4 mr-2" />
Edit
<Pencil className="w-4 h-4" />
{!isMobile && <span className="ml-2">Edit</span>}
</>
) : (
<>
<Eye className="w-4 h-4 mr-2" />
Preview
<Eye className="w-4 h-4" />
{!isMobile && <span className="ml-2">Preview</span>}
</>
)}
</Button>
@@ -921,20 +960,31 @@ export function ContextView() {
onClick={saveFile}
disabled={!hasChanges || isSaving}
data-testid="save-context-file"
aria-label="Save"
title="Save"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
<Save className="w-4 h-4" />
{!isMobile && (
<span className="ml-2">
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
</span>
)}
</Button>
)}
{/* Desktop-only: Delete button (use dropdown on mobile to save space) */}
{!isMobile && (
<Button
variant="outline"
size="sm"
onClick={() => setIsDeleteDialogOpen(true)}
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
data-testid="delete-context-file"
aria-label="Delete"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => setIsDeleteDialogOpen(true)}
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
data-testid="delete-context-file"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
@@ -1072,7 +1122,7 @@ export function ContextView() {
.filter((f): f is globalThis.File => f !== null);
}
const mdFile = files.find((f) => isMarkdownFile(f.name));
const mdFile = files.find((f) => isMarkdownFilename(f.name));
if (mdFile) {
const content = await mdFile.text();
setNewMarkdownContent(content);

View File

@@ -18,7 +18,9 @@ import {
Pencil,
FilePlus,
MoreVertical,
ArrowLeft,
} from 'lucide-react';
import { useIsMobile } from '@/hooks/use-media-query';
import { Spinner } from '@/components/ui/spinner';
import {
Dialog,
@@ -31,6 +33,7 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import { isMarkdownFilename } from '@/lib/image-utils';
import { Markdown } from '../ui/markdown';
import {
DropdownMenu,
@@ -41,6 +44,16 @@ import {
const logger = createLogger('MemoryView');
// Responsive layout classes
const FILE_LIST_BASE_CLASSES = 'border-r border-border flex flex-col overflow-hidden';
const FILE_LIST_DESKTOP_CLASSES = 'w-64';
const FILE_LIST_EXPANDED_CLASSES = 'flex-1';
const FILE_LIST_MOBILE_NO_SELECTION_CLASSES = 'w-full border-r-0';
const FILE_LIST_MOBILE_SELECTION_CLASSES = 'hidden';
const EDITOR_PANEL_BASE_CLASSES = 'flex-1 flex flex-col overflow-hidden';
const EDITOR_PANEL_MOBILE_HIDDEN_CLASSES = 'hidden';
interface MemoryFile {
name: string;
content?: string;
@@ -68,17 +81,15 @@ export function MemoryView() {
// Actions panel state (for tablet/mobile)
const [showActionsPanel, setShowActionsPanel] = useState(false);
// Mobile detection
const isMobile = useIsMobile();
// Get memory directory path
const getMemoryPath = useCallback(() => {
if (!currentProject) return null;
return `${currentProject.path}/.automaker/memory`;
}, [currentProject]);
const isMarkdownFile = (filename: string): boolean => {
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
return ext === '.md' || ext === '.markdown';
};
// Load memory files
const loadMemoryFiles = useCallback(async () => {
const memoryPath = getMemoryPath();
@@ -95,7 +106,7 @@ export function MemoryView() {
const result = await api.readdir(memoryPath);
if (result.success && result.entries) {
const files: MemoryFile[] = result.entries
.filter((entry) => entry.isFile && isMarkdownFile(entry.name))
.filter((entry) => entry.isFile && isMarkdownFilename(entry.name))
.map((entry) => ({
name: entry.name,
path: `${memoryPath}/${entry.name}`,
@@ -130,9 +141,8 @@ export function MemoryView() {
// Select a file
const handleSelectFile = (file: MemoryFile) => {
if (hasChanges) {
// Could add a confirmation dialog here
}
// Note: Unsaved changes warning could be added here in the future
// For now, silently proceed to avoid disrupting mobile UX flow
loadFileContent(file);
setIsPreviewMode(true);
};
@@ -381,7 +391,17 @@ export function MemoryView() {
{/* Main content area with file list and editor */}
<div className="flex-1 flex overflow-hidden">
{/* Left Panel - File List */}
<div className="w-64 border-r border-border flex flex-col overflow-hidden">
{/* Mobile: Full width, hidden when file is selected (full-screen editor) */}
{/* Desktop: Fixed width w-64, expands to fill space when no file selected */}
<div
className={cn(
FILE_LIST_BASE_CLASSES,
FILE_LIST_DESKTOP_CLASSES,
!selectedFile && FILE_LIST_EXPANDED_CLASSES,
isMobile && !selectedFile && FILE_LIST_MOBILE_NO_SELECTION_CLASSES,
isMobile && selectedFile && FILE_LIST_MOBILE_SELECTION_CLASSES
)}
>
<div className="p-3 border-b border-border">
<h2 className="text-sm font-semibold text-muted-foreground">
Memory Files ({memoryFiles.length})
@@ -455,31 +475,53 @@ export function MemoryView() {
</div>
{/* Right Panel - Editor/Preview */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Mobile: Hidden when no file selected (file list shows full screen) */}
<div
className={cn(
EDITOR_PANEL_BASE_CLASSES,
isMobile && !selectedFile && EDITOR_PANEL_MOBILE_HIDDEN_CLASSES
)}
>
{selectedFile ? (
<>
{/* File toolbar */}
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
<div className="flex items-center gap-2 min-w-0">
{/* Mobile-only: Back button to return to file list */}
{isMobile && (
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedFile(null)}
className="shrink-0 -ml-1"
aria-label="Back"
title="Back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
)}
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm font-medium truncate">{selectedFile.name}</span>
</div>
<div className="flex gap-2">
<div className={cn('flex gap-2', isMobile && 'gap-1')}>
{/* Mobile: Icon-only buttons with aria-labels for accessibility */}
<Button
variant="outline"
size="sm"
onClick={() => setIsPreviewMode(!isPreviewMode)}
data-testid="toggle-preview-mode"
aria-label={isPreviewMode ? 'Edit' : 'Preview'}
title={isPreviewMode ? 'Edit' : 'Preview'}
>
{isPreviewMode ? (
<>
<Pencil className="w-4 h-4 mr-2" />
Edit
<Pencil className="w-4 h-4" />
{!isMobile && <span className="ml-2">Edit</span>}
</>
) : (
<>
<Eye className="w-4 h-4 mr-2" />
Preview
<Eye className="w-4 h-4" />
{!isMobile && <span className="ml-2">Preview</span>}
</>
)}
</Button>
@@ -488,19 +530,30 @@ export function MemoryView() {
onClick={saveFile}
disabled={!hasChanges || isSaving}
data-testid="save-memory-file"
aria-label="Save"
title="Save"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setIsDeleteDialogOpen(true)}
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
data-testid="delete-memory-file"
>
<Trash2 className="w-4 h-4" />
<Save className="w-4 h-4" />
{!isMobile && (
<span className="ml-2">
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
</span>
)}
</Button>
{/* Desktop-only: Delete button (use dropdown on mobile to save space) */}
{!isMobile && (
<Button
variant="outline"
size="sm"
onClick={() => setIsDeleteDialogOpen(true)}
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
data-testid="delete-memory-file"
aria-label="Delete"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
</div>