Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ coverage/
*.lcov
playwright-report/
blob-report/
test/**/test-project-[0-9]*/
test/opus-thinking-*/
test/agent-session-test-*/
test/feature-backlog-test-*/
test/running-task-display-test-*/

# Environment files (keep .example)
.env
Expand Down
4 changes: 4 additions & 0 deletions apps/ui/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ export default defineConfig({
ALLOWED_ROOT_DIRECTORY: '',
// Simulate containerized environment to skip sandbox confirmation dialogs
IS_CONTAINERIZED: 'true',
// Increase Node.js memory limit to prevent OOM during tests
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--max-old-space-size=4096']
.filter(Boolean)
.join(' '),
},
},
]),
Expand Down
1 change: 1 addition & 0 deletions apps/ui/src/components/ui/header-actions-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export function HeaderActionsPanelTrigger({
onClick={onToggle}
className={cn('h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden', className)}
aria-label={isOpen ? 'Close actions menu' : 'Open actions menu'}
data-testid="header-actions-panel-trigger"
>
{isOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</Button>
Expand Down
139 changes: 97 additions & 42 deletions apps/ui/src/components/views/context-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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(
() => [
Expand All @@ -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();
Expand Down Expand Up @@ -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,
}));
Expand Down Expand Up @@ -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));
};
Comment on lines +243 to 247
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unsaved edits can now be lost without confirmation.

Line 243–247 removes the unsaved-change safeguard; switching files can discard in-progress content with no prompt.

🧯 Suggested guard
  const handleSelectFile = (file: ContextFile) => {
-    // Note: Unsaved changes warning could be added here in the future
-    // For now, silently proceed to avoid disrupting mobile UX flow
+    if (hasChanges && selectedFile?.path !== file.path) {
+      const shouldDiscard = window.confirm('Discard unsaved changes?');
+      if (!shouldDiscard) return;
+    }
     loadFileContent(file);
     setIsPreviewMode(isMarkdownFilename(file.name));
  };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ui/src/components/views/context-view.tsx` around lines 243 - 247, The
change removed the unsaved-edits safeguard when switching files; restore a guard
in the file-switch handler that checks the editor dirty flag (e.g., isDirty or
hasUnsavedChanges) before calling loadFileContent(file) and
setIsPreviewMode(...). If dirty, show a confirmation modal or browser confirm
and only proceed to call loadFileContent and setIsPreviewMode when the user
confirms (or provide a “Save first” action); keep the safeguard logic colocated
with the existing handler that currently invokes loadFileContent and
setIsPreviewMode.


// Save current file
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -844,7 +861,12 @@ export function ContextView() {
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-accent rounded transition-opacity"
className={cn(
'p-1 hover:bg-accent rounded transition-opacity',
isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
)}
aria-label={`Actions for ${file.name}`}
aria-haspopup="menu"
data-testid={`context-file-menu-${file.name}`}
>
<MoreVertical className="w-4 h-4" />
Expand Down Expand Up @@ -881,36 +903,58 @@ 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" />
) : (
<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">
{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>
Expand All @@ -921,20 +965,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" />
{!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"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
<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>

Expand Down Expand Up @@ -1072,7 +1127,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);
Expand Down
Loading