mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 21:23:07 +00:00
Merge pull request #211 from AutoMaker-Org/kanban-scaling
Kanban scaling
This commit is contained in:
1282
apps/app/server-bundle/package-lock.json
generated
Normal file
1282
apps/app/server-bundle/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
apps/app/server-bundle/package.json
Normal file
15
apps/app/server-bundle/package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@automaker/server-bundle",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/claude-agent-sdk": "^0.1.61",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"morgan": "^1.10.1",
|
||||||
|
"node-pty": "1.1.0-beta41",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,12 +24,12 @@ describe('model-resolver.ts', () => {
|
|||||||
describe('resolveModelString', () => {
|
describe('resolveModelString', () => {
|
||||||
it("should resolve 'haiku' alias to full model string", () => {
|
it("should resolve 'haiku' alias to full model string", () => {
|
||||||
const result = resolveModelString('haiku');
|
const result = resolveModelString('haiku');
|
||||||
expect(result).toBe('claude-haiku-4-5');
|
expect(result).toBe(CLAUDE_MODEL_MAP.haiku);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should resolve 'sonnet' alias to full model string", () => {
|
it("should resolve 'sonnet' alias to full model string", () => {
|
||||||
const result = resolveModelString('sonnet');
|
const result = resolveModelString('sonnet');
|
||||||
expect(result).toBe('claude-sonnet-4-20250514');
|
expect(result).toBe(CLAUDE_MODEL_MAP.sonnet);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should resolve 'opus' alias to full model string", () => {
|
it("should resolve 'opus' alias to full model string", () => {
|
||||||
@@ -50,7 +50,7 @@ describe('model-resolver.ts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should pass through full Claude model strings', () => {
|
it('should pass through full Claude model strings', () => {
|
||||||
const models = ['claude-opus-4-5-20251101', 'claude-sonnet-4-20250514', 'claude-haiku-4-5'];
|
const models = [CLAUDE_MODEL_MAP.opus, CLAUDE_MODEL_MAP.sonnet, CLAUDE_MODEL_MAP.haiku];
|
||||||
models.forEach((model) => {
|
models.forEach((model) => {
|
||||||
const result = resolveModelString(model);
|
const result = resolveModelString(model);
|
||||||
expect(result).toBe(model);
|
expect(result).toBe(model);
|
||||||
@@ -93,11 +93,11 @@ describe('model-resolver.ts', () => {
|
|||||||
|
|
||||||
it('should use session model when explicit is not provided', () => {
|
it('should use session model when explicit is not provided', () => {
|
||||||
const result = getEffectiveModel(undefined, 'sonnet', 'gpt-5.2');
|
const result = getEffectiveModel(undefined, 'sonnet', 'gpt-5.2');
|
||||||
expect(result).toBe('claude-sonnet-4-20250514');
|
expect(result).toBe(CLAUDE_MODEL_MAP.sonnet);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use default when neither explicit nor session is provided', () => {
|
it('should use default when neither explicit nor session is provided', () => {
|
||||||
const customDefault = 'claude-haiku-4-5';
|
const customDefault = CLAUDE_MODEL_MAP.haiku;
|
||||||
const result = getEffectiveModel(undefined, undefined, customDefault);
|
const result = getEffectiveModel(undefined, undefined, customDefault);
|
||||||
expect(result).toBe(customDefault);
|
expect(result).toBe(customDefault);
|
||||||
});
|
});
|
||||||
@@ -109,7 +109,7 @@ describe('model-resolver.ts', () => {
|
|||||||
|
|
||||||
it('should handle explicit empty strings as undefined', () => {
|
it('should handle explicit empty strings as undefined', () => {
|
||||||
const result = getEffectiveModel('', 'haiku');
|
const result = getEffectiveModel('', 'haiku');
|
||||||
expect(result).toBe('claude-haiku-4-5');
|
expect(result).toBe(CLAUDE_MODEL_MAP.haiku);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -307,10 +307,10 @@ describe('claude-provider.ts', () => {
|
|||||||
expect(sonnet35).toBeDefined();
|
expect(sonnet35).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include Claude 3.5 Haiku', () => {
|
it('should include Claude Haiku 4.5', () => {
|
||||||
const models = provider.getAvailableModels();
|
const models = provider.getAvailableModels();
|
||||||
|
|
||||||
const haiku = models.find((m) => m.id === 'claude-3-5-haiku-20241022');
|
const haiku = models.find((m) => m.id === 'claude-haiku-4-5-20251001');
|
||||||
expect(haiku).toBeDefined();
|
expect(haiku).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export default defineConfig({
|
|||||||
'src/**/*.d.ts',
|
'src/**/*.d.ts',
|
||||||
'src/index.ts',
|
'src/index.ts',
|
||||||
'src/routes/**', // Routes are better tested with integration tests
|
'src/routes/**', // Routes are better tested with integration tests
|
||||||
|
'src/types/**', // Type re-exports don't need coverage
|
||||||
|
'src/middleware/**', // Middleware needs integration tests
|
||||||
|
'src/lib/enhancement-prompts.ts', // Prompt templates don't need unit tests
|
||||||
|
'src/services/claude-usage-service.ts', // TODO: Add tests for usage tracking
|
||||||
|
'**/libs/**', // Exclude aliased shared packages from server coverage
|
||||||
],
|
],
|
||||||
thresholds: {
|
thresholds: {
|
||||||
// Increased thresholds to ensure better code quality
|
// Increased thresholds to ensure better code quality
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export function Sidebar() {
|
|||||||
const isCreatingSpec = specCreatingForProject !== null;
|
const isCreatingSpec = specCreatingForProject !== null;
|
||||||
const creatingSpecProjectPath = specCreatingForProject;
|
const creatingSpecProjectPath = specCreatingForProject;
|
||||||
|
|
||||||
// Auto-collapse sidebar on small screens
|
// Auto-collapse sidebar on small screens and update Electron window minWidth
|
||||||
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
|
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
|
||||||
|
|
||||||
// Running agents count
|
// Running agents count
|
||||||
|
|||||||
@@ -32,4 +32,17 @@ export function useSidebarAutoCollapse({
|
|||||||
mediaQuery.addEventListener('change', handleResize);
|
mediaQuery.addEventListener('change', handleResize);
|
||||||
return () => mediaQuery.removeEventListener('change', handleResize);
|
return () => mediaQuery.removeEventListener('change', handleResize);
|
||||||
}, [sidebarOpen, toggleSidebar]);
|
}, [sidebarOpen, toggleSidebar]);
|
||||||
|
|
||||||
|
// Update Electron window minWidth when sidebar state changes
|
||||||
|
// This ensures the window can't be resized smaller than what the kanban board needs
|
||||||
|
useEffect(() => {
|
||||||
|
const electronAPI = (
|
||||||
|
window as unknown as {
|
||||||
|
electronAPI?: { updateMinWidth?: (expanded: boolean) => Promise<void> };
|
||||||
|
}
|
||||||
|
).electronAPI;
|
||||||
|
if (electronAPI?.updateMinWidth) {
|
||||||
|
electronAPI.updateMinWidth(sidebarOpen);
|
||||||
|
}
|
||||||
|
}, [sidebarOpen]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex flex-col h-full rounded-xl transition-all duration-200',
|
'relative flex flex-col h-full rounded-xl',
|
||||||
|
// Only transition ring/shadow for drag-over effect, not width
|
||||||
|
'transition-[box-shadow,ring] duration-200',
|
||||||
!width && 'w-72', // Only apply w-72 if no custom width
|
!width && 'w-72', // Only apply w-72 if no custom width
|
||||||
showBorder && 'border border-border/60',
|
showBorder && 'border border-border/60',
|
||||||
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background'
|
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background'
|
||||||
|
|||||||
@@ -82,17 +82,18 @@ export function KanbanBoard({
|
|||||||
onArchiveAllVerified,
|
onArchiveAllVerified,
|
||||||
}: KanbanBoardProps) {
|
}: KanbanBoardProps) {
|
||||||
// Use responsive column widths based on window size
|
// Use responsive column widths based on window size
|
||||||
const { columnWidth } = useResponsiveKanban(COLUMNS.length);
|
// containerStyle handles centering and ensures columns fit without horizontal scroll in Electron
|
||||||
|
const { columnWidth, containerStyle } = useResponsiveKanban(COLUMNS.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-x-auto px-4 pb-4 relative" style={backgroundImageStyle}>
|
<div className="flex-1 overflow-x-hidden px-5 pb-4 relative" style={backgroundImageStyle}>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={collisionDetectionStrategy}
|
collisionDetection={collisionDetectionStrategy}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
>
|
>
|
||||||
<div className="flex gap-5 h-full py-1 justify-center">
|
<div className="h-full py-1" style={containerStyle}>
|
||||||
{COLUMNS.map((column) => {
|
{COLUMNS.map((column) => {
|
||||||
const columnFeatures = getColumnFeatures(column.id);
|
const columnFeatures = getColumnFeatures(column.id);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1000,6 +1000,33 @@ export function ContextView() {
|
|||||||
id="markdown-content"
|
id="markdown-content"
|
||||||
value={newMarkdownContent}
|
value={newMarkdownContent}
|
||||||
onChange={(e) => setNewMarkdownContent(e.target.value)}
|
onChange={(e) => setNewMarkdownContent(e.target.value)}
|
||||||
|
onDrop={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Try files first, then items for better compatibility
|
||||||
|
let files = Array.from(e.dataTransfer.files);
|
||||||
|
if (files.length === 0 && e.dataTransfer.items) {
|
||||||
|
const items = Array.from(e.dataTransfer.items);
|
||||||
|
files = items
|
||||||
|
.filter((item) => item.kind === 'file')
|
||||||
|
.map((item) => item.getAsFile())
|
||||||
|
.filter((f): f is globalThis.File => f !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mdFile = files.find((f) => isMarkdownFile(f.name));
|
||||||
|
if (mdFile) {
|
||||||
|
const content = await mdFile.text();
|
||||||
|
setNewMarkdownContent(content);
|
||||||
|
if (!newMarkdownName.trim()) {
|
||||||
|
setNewMarkdownName(mdFile.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
placeholder="Enter your markdown content here..."
|
placeholder="Enter your markdown content here..."
|
||||||
className="w-full h-60 p-3 font-mono text-sm bg-background border border-border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent"
|
className="w-full h-60 p-3 font-mono text-sm bg-background border border-border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
|
||||||
export interface ResponsiveKanbanConfig {
|
export interface ResponsiveKanbanConfig {
|
||||||
columnWidth: number;
|
columnWidth: number;
|
||||||
@@ -13,16 +14,21 @@ export interface ResponsiveKanbanConfig {
|
|||||||
*/
|
*/
|
||||||
const DEFAULT_CONFIG: ResponsiveKanbanConfig = {
|
const DEFAULT_CONFIG: ResponsiveKanbanConfig = {
|
||||||
columnWidth: 288, // 18rem = 288px (w-72)
|
columnWidth: 288, // 18rem = 288px (w-72)
|
||||||
columnMinWidth: 280, // Minimum column width - increased to ensure usability
|
columnMinWidth: 280, // Minimum column width - ensures usability
|
||||||
columnMaxWidth: 360, // Maximum column width to ensure responsive scaling
|
columnMaxWidth: Infinity, // No max width - columns scale evenly to fill viewport
|
||||||
gap: 20, // gap-5 = 20px
|
gap: 20, // gap-5 = 20px
|
||||||
padding: 32, // px-4 on both sides = 32px
|
padding: 40, // px-5 on both sides = 40px (matches gap between columns)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sidebar transition duration (matches sidebar.tsx)
|
||||||
|
const SIDEBAR_TRANSITION_MS = 300;
|
||||||
|
|
||||||
export interface UseResponsiveKanbanResult {
|
export interface UseResponsiveKanbanResult {
|
||||||
columnWidth: number;
|
columnWidth: number;
|
||||||
containerStyle: React.CSSProperties;
|
containerStyle: React.CSSProperties;
|
||||||
isCompact: boolean;
|
isCompact: boolean;
|
||||||
|
totalBoardWidth: number;
|
||||||
|
isInitialized: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,9 +36,14 @@ export interface UseResponsiveKanbanResult {
|
|||||||
* Ensures columns scale intelligently to fill available space without
|
* Ensures columns scale intelligently to fill available space without
|
||||||
* dead space on the right or content being cut off.
|
* dead space on the right or content being cut off.
|
||||||
*
|
*
|
||||||
|
* Features:
|
||||||
|
* - Uses useLayoutEffect to calculate width before paint (prevents bounce)
|
||||||
|
* - Observes actual board container for accurate sizing
|
||||||
|
* - Recalculates after sidebar transitions
|
||||||
|
*
|
||||||
* @param columnCount - Number of columns in the Kanban board
|
* @param columnCount - Number of columns in the Kanban board
|
||||||
* @param config - Optional configuration for column sizing
|
* @param config - Optional configuration for column sizing
|
||||||
* @returns Object with calculated column width and container styles
|
* @returns Object with calculated column width, container styles, and metrics
|
||||||
*/
|
*/
|
||||||
export function useResponsiveKanban(
|
export function useResponsiveKanban(
|
||||||
columnCount: number = 4,
|
columnCount: number = 4,
|
||||||
@@ -43,68 +54,129 @@ export function useResponsiveKanban(
|
|||||||
...config,
|
...config,
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateColumnWidth = useCallback(() => {
|
const sidebarOpen = useAppStore((state) => state.sidebarOpen);
|
||||||
if (typeof window === 'undefined') {
|
const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
return DEFAULT_CONFIG.columnWidth;
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
}
|
|
||||||
|
|
||||||
// Get the actual board container width
|
const calculateColumnWidth = useCallback(
|
||||||
// The flex layout already accounts for sidebar width, so we use the container's actual width
|
(containerWidth?: number) => {
|
||||||
const boardContainer = document.querySelector('[data-testid="board-view"]')?.parentElement;
|
if (typeof window === 'undefined') {
|
||||||
const containerWidth = boardContainer ? boardContainer.clientWidth : window.innerWidth;
|
return DEFAULT_CONFIG.columnWidth;
|
||||||
|
}
|
||||||
|
|
||||||
// Get the available width (subtract padding only)
|
// Get the actual board container width
|
||||||
const availableWidth = containerWidth - padding;
|
// The flex layout already accounts for sidebar width, so we use the container's actual width
|
||||||
|
let width = containerWidth;
|
||||||
|
if (width === undefined) {
|
||||||
|
const boardContainer = document.querySelector('[data-testid="board-view"]')?.parentElement;
|
||||||
|
width = boardContainer ? boardContainer.clientWidth : window.innerWidth;
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate total gap space needed
|
// Get the available width (subtract padding only)
|
||||||
const totalGapWidth = gap * (columnCount - 1);
|
const availableWidth = width - padding;
|
||||||
|
|
||||||
// Calculate width available for all columns
|
// Calculate total gap space needed
|
||||||
const widthForColumns = availableWidth - totalGapWidth;
|
const totalGapWidth = gap * (columnCount - 1);
|
||||||
|
|
||||||
// Calculate ideal column width
|
// Calculate width available for all columns
|
||||||
let idealWidth = Math.floor(widthForColumns / columnCount);
|
const widthForColumns = availableWidth - totalGapWidth;
|
||||||
|
|
||||||
// Clamp to min/max bounds
|
// Calculate ideal column width
|
||||||
idealWidth = Math.max(columnMinWidth, Math.min(columnMaxWidth, idealWidth));
|
let idealWidth = Math.floor(widthForColumns / columnCount);
|
||||||
|
|
||||||
return idealWidth;
|
// Clamp to min/max bounds
|
||||||
}, [columnCount, columnMinWidth, columnMaxWidth, gap, padding]);
|
idealWidth = Math.max(columnMinWidth, Math.min(columnMaxWidth, idealWidth));
|
||||||
|
|
||||||
|
return idealWidth;
|
||||||
|
},
|
||||||
|
[columnCount, columnMinWidth, columnMaxWidth, gap, padding]
|
||||||
|
);
|
||||||
|
|
||||||
const [columnWidth, setColumnWidth] = useState<number>(() => calculateColumnWidth());
|
const [columnWidth, setColumnWidth] = useState<number>(() => calculateColumnWidth());
|
||||||
|
|
||||||
|
// Use useLayoutEffect to calculate width synchronously before paint
|
||||||
|
// This prevents the "bounce" effect when navigating to the kanban view
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const updateWidth = () => {
|
||||||
|
const newWidth = calculateColumnWidth();
|
||||||
|
setColumnWidth(newWidth);
|
||||||
|
setIsInitialized(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate immediately before paint
|
||||||
|
updateWidth();
|
||||||
|
}, [calculateColumnWidth]);
|
||||||
|
|
||||||
|
// Set up ResizeObserver for ongoing resize handling
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
const handleResize = () => {
|
const updateWidth = () => {
|
||||||
const newWidth = calculateColumnWidth();
|
const newWidth = calculateColumnWidth();
|
||||||
setColumnWidth(newWidth);
|
setColumnWidth(newWidth);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set initial width
|
// Debounced update for smooth resize transitions
|
||||||
handleResize();
|
const scheduleUpdate = () => {
|
||||||
|
if (resizeTimeoutRef.current) {
|
||||||
|
clearTimeout(resizeTimeoutRef.current);
|
||||||
|
}
|
||||||
|
resizeTimeoutRef.current = setTimeout(updateWidth, 50);
|
||||||
|
};
|
||||||
|
|
||||||
// Use ResizeObserver for more precise updates if available
|
// Use ResizeObserver on the actual board container for precise updates
|
||||||
if (typeof ResizeObserver !== 'undefined') {
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
const observer = new ResizeObserver(handleResize);
|
const boardView = document.querySelector('[data-testid="board-view"]');
|
||||||
observer.observe(document.body);
|
const container = boardView?.parentElement;
|
||||||
|
|
||||||
return () => {
|
if (container && typeof ResizeObserver !== 'undefined') {
|
||||||
observer.disconnect();
|
resizeObserver = new ResizeObserver((entries) => {
|
||||||
};
|
// Use the observed container's width for calculation
|
||||||
|
const entry = entries[0];
|
||||||
|
if (entry) {
|
||||||
|
const containerWidth = entry.contentRect.width;
|
||||||
|
const newWidth = calculateColumnWidth(containerWidth);
|
||||||
|
setColumnWidth(newWidth);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resizeObserver.observe(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to window resize event
|
// Fallback to window resize event
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', scheduleUpdate);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', handleResize);
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', scheduleUpdate);
|
||||||
|
if (resizeTimeoutRef.current) {
|
||||||
|
clearTimeout(resizeTimeoutRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [calculateColumnWidth]);
|
}, [calculateColumnWidth]);
|
||||||
|
|
||||||
|
// Re-calculate after sidebar transitions complete
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
const newWidth = calculateColumnWidth();
|
||||||
|
setColumnWidth(newWidth);
|
||||||
|
}, SIDEBAR_TRANSITION_MS + 50); // Wait for transition to complete
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [sidebarOpen, calculateColumnWidth]);
|
||||||
|
|
||||||
// Determine if we're in compact mode (columns at minimum width)
|
// Determine if we're in compact mode (columns at minimum width)
|
||||||
const isCompact = columnWidth <= columnMinWidth + 10;
|
const isCompact = columnWidth <= columnMinWidth + 10;
|
||||||
|
|
||||||
// Container style to center content and prevent overflow
|
// Calculate total board width for container sizing
|
||||||
|
const totalBoardWidth = columnWidth * columnCount + gap * (columnCount - 1);
|
||||||
|
|
||||||
|
// Container style to center content
|
||||||
|
// Use flex layout with justify-center to naturally center columns
|
||||||
|
// The parent container has px-4 padding which provides equal left/right margins
|
||||||
const containerStyle: React.CSSProperties = {
|
const containerStyle: React.CSSProperties = {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: `${gap}px`,
|
gap: `${gap}px`,
|
||||||
@@ -116,5 +188,7 @@ export function useResponsiveKanban(
|
|||||||
columnWidth,
|
columnWidth,
|
||||||
containerStyle,
|
containerStyle,
|
||||||
isCompact,
|
isCompact,
|
||||||
|
totalBoardWidth,
|
||||||
|
isInitialized,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import path from 'path';
|
|||||||
import { spawn, ChildProcess } from 'child_process';
|
import { spawn, ChildProcess } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import http, { Server } from 'http';
|
import http, { Server } from 'http';
|
||||||
import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron';
|
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
|
||||||
|
|
||||||
// Development environment
|
// Development environment
|
||||||
const isDev = !app.isPackaged;
|
const isDev = !app.isPackaged;
|
||||||
@@ -31,6 +31,39 @@ let staticServer: Server | null = null;
|
|||||||
const SERVER_PORT = 3008;
|
const SERVER_PORT = 3008;
|
||||||
const STATIC_PORT = 3007;
|
const STATIC_PORT = 3007;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Window sizing constants for kanban layout
|
||||||
|
// ============================================
|
||||||
|
// Calculation: 4 columns × 280px + 3 gaps × 20px + 40px padding = 1220px board content
|
||||||
|
// With sidebar expanded (288px): 1220 + 288 = 1508px
|
||||||
|
// With sidebar collapsed (64px): 1220 + 64 = 1284px
|
||||||
|
const COLUMN_MIN_WIDTH = 280;
|
||||||
|
const COLUMN_COUNT = 4;
|
||||||
|
const GAP_SIZE = 20;
|
||||||
|
const BOARD_PADDING = 40; // px-5 on both sides = 40px (matches gap between columns)
|
||||||
|
const SIDEBAR_EXPANDED = 288;
|
||||||
|
const SIDEBAR_COLLAPSED = 64;
|
||||||
|
|
||||||
|
const BOARD_CONTENT_MIN =
|
||||||
|
COLUMN_MIN_WIDTH * COLUMN_COUNT + GAP_SIZE * (COLUMN_COUNT - 1) + BOARD_PADDING;
|
||||||
|
const MIN_WIDTH_EXPANDED = BOARD_CONTENT_MIN + SIDEBAR_EXPANDED; // 1500px
|
||||||
|
const MIN_WIDTH_COLLAPSED = BOARD_CONTENT_MIN + SIDEBAR_COLLAPSED; // 1276px
|
||||||
|
const MIN_HEIGHT = 650; // Ensures sidebar content fits without scrolling
|
||||||
|
const DEFAULT_WIDTH = 1600;
|
||||||
|
const DEFAULT_HEIGHT = 950;
|
||||||
|
|
||||||
|
// Window bounds interface (matches @automaker/types WindowBounds)
|
||||||
|
interface WindowBounds {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
isMaximized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce timer for saving window bounds
|
||||||
|
let saveWindowBoundsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get icon path - works in both dev and production, cross-platform
|
* Get icon path - works in both dev and production, cross-platform
|
||||||
*/
|
*/
|
||||||
@@ -56,6 +89,120 @@ function getIconPath(): string | null {
|
|||||||
return iconPath;
|
return iconPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get path to window bounds settings file
|
||||||
|
*/
|
||||||
|
function getWindowBoundsPath(): string {
|
||||||
|
return path.join(app.getPath('userData'), 'window-bounds.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load saved window bounds from disk
|
||||||
|
*/
|
||||||
|
function loadWindowBounds(): WindowBounds | null {
|
||||||
|
try {
|
||||||
|
const boundsPath = getWindowBoundsPath();
|
||||||
|
if (fs.existsSync(boundsPath)) {
|
||||||
|
const data = fs.readFileSync(boundsPath, 'utf-8');
|
||||||
|
const bounds = JSON.parse(data) as WindowBounds;
|
||||||
|
// Validate the loaded data has required fields
|
||||||
|
if (
|
||||||
|
typeof bounds.x === 'number' &&
|
||||||
|
typeof bounds.y === 'number' &&
|
||||||
|
typeof bounds.width === 'number' &&
|
||||||
|
typeof bounds.height === 'number'
|
||||||
|
) {
|
||||||
|
return bounds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Electron] Failed to load window bounds:', (error as Error).message);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save window bounds to disk
|
||||||
|
*/
|
||||||
|
function saveWindowBounds(bounds: WindowBounds): void {
|
||||||
|
try {
|
||||||
|
const boundsPath = getWindowBoundsPath();
|
||||||
|
fs.writeFileSync(boundsPath, JSON.stringify(bounds, null, 2), 'utf-8');
|
||||||
|
console.log('[Electron] Window bounds saved');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Electron] Failed to save window bounds:', (error as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a debounced save of window bounds (500ms delay)
|
||||||
|
*/
|
||||||
|
function scheduleSaveWindowBounds(): void {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
|
||||||
|
if (saveWindowBoundsTimeout) {
|
||||||
|
clearTimeout(saveWindowBoundsTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveWindowBoundsTimeout = setTimeout(() => {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
|
||||||
|
const isMaximized = mainWindow.isMaximized();
|
||||||
|
// Use getNormalBounds() for maximized windows to save pre-maximized size
|
||||||
|
const bounds = isMaximized ? mainWindow.getNormalBounds() : mainWindow.getBounds();
|
||||||
|
|
||||||
|
saveWindowBounds({
|
||||||
|
x: bounds.x,
|
||||||
|
y: bounds.y,
|
||||||
|
width: bounds.width,
|
||||||
|
height: bounds.height,
|
||||||
|
isMaximized,
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that window bounds are visible on at least one display
|
||||||
|
* Returns adjusted bounds if needed, or null if completely off-screen
|
||||||
|
*/
|
||||||
|
function validateBounds(bounds: WindowBounds): WindowBounds {
|
||||||
|
const displays = screen.getAllDisplays();
|
||||||
|
|
||||||
|
// Check if window center is visible on any display
|
||||||
|
const centerX = bounds.x + bounds.width / 2;
|
||||||
|
const centerY = bounds.y + bounds.height / 2;
|
||||||
|
|
||||||
|
let isVisible = false;
|
||||||
|
for (const display of displays) {
|
||||||
|
const { x, y, width, height } = display.workArea;
|
||||||
|
if (centerX >= x && centerX <= x + width && centerY >= y && centerY <= y + height) {
|
||||||
|
isVisible = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
// Window is off-screen, reset to primary display
|
||||||
|
const primaryDisplay = screen.getPrimaryDisplay();
|
||||||
|
const { x, y, width, height } = primaryDisplay.workArea;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: x + Math.floor((width - bounds.width) / 2),
|
||||||
|
y: y + Math.floor((height - bounds.height) / 2),
|
||||||
|
width: Math.min(bounds.width, width),
|
||||||
|
height: Math.min(bounds.height, height),
|
||||||
|
isMaximized: bounds.isMaximized,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure minimum dimensions
|
||||||
|
return {
|
||||||
|
...bounds,
|
||||||
|
width: Math.max(bounds.width, MIN_WIDTH_EXPANDED),
|
||||||
|
height: Math.max(bounds.height, MIN_HEIGHT),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start static file server for production builds
|
* Start static file server for production builds
|
||||||
*/
|
*/
|
||||||
@@ -246,11 +393,18 @@ async function waitForServer(maxAttempts = 30): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
const iconPath = getIconPath();
|
const iconPath = getIconPath();
|
||||||
|
|
||||||
|
// Load and validate saved window bounds
|
||||||
|
const savedBounds = loadWindowBounds();
|
||||||
|
const validBounds = savedBounds ? validateBounds(savedBounds) : null;
|
||||||
|
|
||||||
const windowOptions: Electron.BrowserWindowConstructorOptions = {
|
const windowOptions: Electron.BrowserWindowConstructorOptions = {
|
||||||
width: 1600,
|
width: validBounds?.width ?? DEFAULT_WIDTH,
|
||||||
height: 950,
|
height: validBounds?.height ?? DEFAULT_HEIGHT,
|
||||||
minWidth: 1280,
|
x: validBounds?.x,
|
||||||
minHeight: 768,
|
y: validBounds?.y,
|
||||||
|
minWidth: MIN_WIDTH_EXPANDED, // 1500px - ensures kanban columns fit with sidebar
|
||||||
|
minHeight: MIN_HEIGHT,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
@@ -266,6 +420,11 @@ function createWindow(): void {
|
|||||||
|
|
||||||
mainWindow = new BrowserWindow(windowOptions);
|
mainWindow = new BrowserWindow(windowOptions);
|
||||||
|
|
||||||
|
// Restore maximized state if previously maximized
|
||||||
|
if (validBounds?.isMaximized) {
|
||||||
|
mainWindow.maximize();
|
||||||
|
}
|
||||||
|
|
||||||
// Load Vite dev server in development or static server in production
|
// Load Vite dev server in development or static server in production
|
||||||
if (VITE_DEV_SERVER_URL) {
|
if (VITE_DEV_SERVER_URL) {
|
||||||
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
||||||
@@ -280,10 +439,35 @@ function createWindow(): void {
|
|||||||
mainWindow.webContents.openDevTools();
|
mainWindow.webContents.openDevTools();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save window bounds on close, resize, and move
|
||||||
|
mainWindow.on('close', () => {
|
||||||
|
// Save immediately before closing (not debounced)
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
const isMaximized = mainWindow.isMaximized();
|
||||||
|
const bounds = isMaximized ? mainWindow.getNormalBounds() : mainWindow.getBounds();
|
||||||
|
|
||||||
|
saveWindowBounds({
|
||||||
|
x: bounds.x,
|
||||||
|
y: bounds.y,
|
||||||
|
width: bounds.width,
|
||||||
|
height: bounds.height,
|
||||||
|
isMaximized,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
mainWindow.on('closed', () => {
|
mainWindow.on('closed', () => {
|
||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mainWindow.on('resized', () => {
|
||||||
|
scheduleSaveWindowBounds();
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on('moved', () => {
|
||||||
|
scheduleSaveWindowBounds();
|
||||||
|
});
|
||||||
|
|
||||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
shell.openExternal(url);
|
shell.openExternal(url);
|
||||||
return { action: 'deny' };
|
return { action: 'deny' };
|
||||||
@@ -460,3 +644,17 @@ ipcMain.handle('ping', async () => {
|
|||||||
ipcMain.handle('server:getUrl', async () => {
|
ipcMain.handle('server:getUrl', async () => {
|
||||||
return `http://localhost:${SERVER_PORT}`;
|
return `http://localhost:${SERVER_PORT}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Window management - update minimum width based on sidebar state
|
||||||
|
ipcMain.handle('window:updateMinWidth', (_, sidebarExpanded: boolean) => {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
|
||||||
|
const minWidth = sidebarExpanded ? MIN_WIDTH_EXPANDED : MIN_WIDTH_COLLAPSED;
|
||||||
|
mainWindow.setMinimumSize(minWidth, MIN_HEIGHT);
|
||||||
|
|
||||||
|
// If current width is below new minimum, resize window
|
||||||
|
const currentBounds = mainWindow.getBounds();
|
||||||
|
if (currentBounds.width < minWidth) {
|
||||||
|
mainWindow.setSize(minWidth, currentBounds.height);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getPath: (name: string): Promise<string> => ipcRenderer.invoke('app:getPath', name),
|
getPath: (name: string): Promise<string> => ipcRenderer.invoke('app:getPath', name),
|
||||||
getVersion: (): Promise<string> => ipcRenderer.invoke('app:getVersion'),
|
getVersion: (): Promise<string> => ipcRenderer.invoke('app:getVersion'),
|
||||||
isPackaged: (): Promise<boolean> => ipcRenderer.invoke('app:isPackaged'),
|
isPackaged: (): Promise<boolean> => ipcRenderer.invoke('app:isPackaged'),
|
||||||
|
|
||||||
|
// Window management
|
||||||
|
updateMinWidth: (sidebarExpanded: boolean): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('window:updateMinWidth', sidebarExpanded),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[Preload] Electron API exposed (TypeScript)');
|
console.log('[Preload] Electron API exposed (TypeScript)');
|
||||||
|
|||||||
@@ -46,28 +46,25 @@ test.describe('Context View - File Management', () => {
|
|||||||
|
|
||||||
await navigateToContext(page);
|
await navigateToContext(page);
|
||||||
|
|
||||||
// Click Add File button
|
// Click Create Markdown button
|
||||||
await clickElement(page, 'add-context-file');
|
await clickElement(page, 'create-markdown-button');
|
||||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
await page.waitForSelector('[data-testid="create-markdown-dialog"]', {
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Select text type (should be default)
|
|
||||||
await clickElement(page, 'add-text-type');
|
|
||||||
|
|
||||||
// Enter filename
|
// Enter filename
|
||||||
await fillInput(page, 'new-file-name', 'test-context.md');
|
await fillInput(page, 'new-markdown-name', 'test-context.md');
|
||||||
|
|
||||||
// Enter content
|
// Enter content
|
||||||
const testContent = '# Test Context\n\nThis is test content';
|
const testContent = '# Test Context\n\nThis is test content';
|
||||||
await fillInput(page, 'new-file-content', testContent);
|
await fillInput(page, 'new-markdown-content', testContent);
|
||||||
|
|
||||||
// Click confirm
|
// Click confirm
|
||||||
await clickElement(page, 'confirm-add-file');
|
await clickElement(page, 'confirm-create-markdown');
|
||||||
|
|
||||||
// Wait for dialog to close
|
// Wait for dialog to close
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
() => !document.querySelector('[data-testid="create-markdown-dialog"]'),
|
||||||
{ timeout: 5000 }
|
{ timeout: 5000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -208,36 +205,16 @@ test.describe('Context View - File Management', () => {
|
|||||||
|
|
||||||
await navigateToContext(page);
|
await navigateToContext(page);
|
||||||
|
|
||||||
// Click Add File button
|
// Use the hidden file input to upload an image directly
|
||||||
await clickElement(page, 'add-context-file');
|
// The "Import File" button triggers this input
|
||||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
const fileInput = page.locator('[data-testid="file-import-input"]');
|
||||||
timeout: 5000,
|
await fileInput.setInputFiles(TEST_IMAGE_SRC);
|
||||||
});
|
|
||||||
|
|
||||||
// Select image type
|
// Wait for file to appear in the list (filename is extracted from path)
|
||||||
await clickElement(page, 'add-image-type');
|
await waitForContextFile(page, 'logo.png', 10000);
|
||||||
|
|
||||||
// Enter filename
|
|
||||||
await fillInput(page, 'new-file-name', 'test-image.png');
|
|
||||||
|
|
||||||
// Upload image using file input
|
|
||||||
await page.setInputFiles('[data-testid="image-upload-input"]', TEST_IMAGE_SRC);
|
|
||||||
|
|
||||||
// Wait for image preview to appear (indicates upload success)
|
|
||||||
const addDialog = await getByTestId(page, 'add-context-dialog');
|
|
||||||
await addDialog.locator('img').waitFor({ state: 'visible' });
|
|
||||||
|
|
||||||
// Click confirm
|
|
||||||
await clickElement(page, 'confirm-add-file');
|
|
||||||
|
|
||||||
// Wait for dialog to close
|
|
||||||
await page.waitForFunction(
|
|
||||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
|
||||||
{ timeout: 5000 }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify file appears in list
|
// Verify file appears in list
|
||||||
const fileButton = await getByTestId(page, 'context-file-test-image.png');
|
const fileButton = await getByTestId(page, 'context-file-logo.png');
|
||||||
await expect(fileButton).toBeVisible();
|
await expect(fileButton).toBeVisible();
|
||||||
|
|
||||||
// Click on the image to view it
|
// Click on the image to view it
|
||||||
@@ -362,26 +339,23 @@ test.describe('Context View - Drag and Drop', () => {
|
|||||||
|
|
||||||
await navigateToContext(page);
|
await navigateToContext(page);
|
||||||
|
|
||||||
// Open add file dialog
|
// Open create markdown dialog
|
||||||
await clickElement(page, 'add-context-file');
|
await clickElement(page, 'create-markdown-button');
|
||||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
await page.waitForSelector('[data-testid="create-markdown-dialog"]', {
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure text type is selected
|
|
||||||
await clickElement(page, 'add-text-type');
|
|
||||||
|
|
||||||
// Simulate drag and drop of a .md file onto the textarea
|
// Simulate drag and drop of a .md file onto the textarea
|
||||||
const droppedContent = '# Dropped Content\n\nThis was dragged and dropped.';
|
const droppedContent = '# Dropped Content\n\nThis was dragged and dropped.';
|
||||||
await simulateFileDrop(
|
await simulateFileDrop(
|
||||||
page,
|
page,
|
||||||
'[data-testid="new-file-content"]',
|
'[data-testid="new-markdown-content"]',
|
||||||
'dropped-file.md',
|
'dropped-file.md',
|
||||||
droppedContent
|
droppedContent
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait for content to be populated in textarea
|
// Wait for content to be populated in textarea
|
||||||
const textarea = await getByTestId(page, 'new-file-content');
|
const textarea = await getByTestId(page, 'new-markdown-content');
|
||||||
await textarea.waitFor({ state: 'visible' });
|
await textarea.waitFor({ state: 'visible' });
|
||||||
await expect(textarea).toHaveValue(droppedContent);
|
await expect(textarea).toHaveValue(droppedContent);
|
||||||
|
|
||||||
@@ -390,15 +364,15 @@ test.describe('Context View - Drag and Drop', () => {
|
|||||||
expect(textareaContent).toBe(droppedContent);
|
expect(textareaContent).toBe(droppedContent);
|
||||||
|
|
||||||
// Verify filename is auto-filled
|
// Verify filename is auto-filled
|
||||||
const filenameValue = await page.locator('[data-testid="new-file-name"]').inputValue();
|
const filenameValue = await page.locator('[data-testid="new-markdown-name"]').inputValue();
|
||||||
expect(filenameValue).toBe('dropped-file.md');
|
expect(filenameValue).toBe('dropped-file.md');
|
||||||
|
|
||||||
// Confirm and create the file
|
// Confirm and create the file
|
||||||
await clickElement(page, 'confirm-add-file');
|
await clickElement(page, 'confirm-create-markdown');
|
||||||
|
|
||||||
// Wait for dialog to close
|
// Wait for dialog to close
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
() => !document.querySelector('[data-testid="create-markdown-dialog"]'),
|
||||||
{ timeout: 5000 }
|
{ timeout: 5000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -473,20 +447,19 @@ test.describe('Context View - Edge Cases', () => {
|
|||||||
await expect(originalFile).toBeVisible();
|
await expect(originalFile).toBeVisible();
|
||||||
|
|
||||||
// Try to create another file with the same name
|
// Try to create another file with the same name
|
||||||
await clickElement(page, 'add-context-file');
|
await clickElement(page, 'create-markdown-button');
|
||||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
await page.waitForSelector('[data-testid="create-markdown-dialog"]', {
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await clickElement(page, 'add-text-type');
|
await fillInput(page, 'new-markdown-name', 'test.md');
|
||||||
await fillInput(page, 'new-file-name', 'test.md');
|
await fillInput(page, 'new-markdown-content', '# New Content - Overwritten');
|
||||||
await fillInput(page, 'new-file-content', '# New Content - Overwritten');
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-add-file');
|
await clickElement(page, 'confirm-create-markdown');
|
||||||
|
|
||||||
// Wait for dialog to close
|
// Wait for dialog to close
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
() => !document.querySelector('[data-testid="create-markdown-dialog"]'),
|
||||||
{ timeout: 5000 }
|
{ timeout: 5000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -518,18 +491,17 @@ test.describe('Context View - Edge Cases', () => {
|
|||||||
await navigateToContext(page);
|
await navigateToContext(page);
|
||||||
|
|
||||||
// Test file with parentheses
|
// Test file with parentheses
|
||||||
await clickElement(page, 'add-context-file');
|
await clickElement(page, 'create-markdown-button');
|
||||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
await page.waitForSelector('[data-testid="create-markdown-dialog"]', {
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await clickElement(page, 'add-text-type');
|
await fillInput(page, 'new-markdown-name', 'context (1).md');
|
||||||
await fillInput(page, 'new-file-name', 'context (1).md');
|
await fillInput(page, 'new-markdown-content', 'Content with parentheses in filename');
|
||||||
await fillInput(page, 'new-file-content', 'Content with parentheses in filename');
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-add-file');
|
await clickElement(page, 'confirm-create-markdown');
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
() => !document.querySelector('[data-testid="create-markdown-dialog"]'),
|
||||||
{ timeout: 5000 }
|
{ timeout: 5000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -538,18 +510,17 @@ test.describe('Context View - Edge Cases', () => {
|
|||||||
await expect(fileWithParens).toBeVisible();
|
await expect(fileWithParens).toBeVisible();
|
||||||
|
|
||||||
// Test file with hyphens and underscores
|
// Test file with hyphens and underscores
|
||||||
await clickElement(page, 'add-context-file');
|
await clickElement(page, 'create-markdown-button');
|
||||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
await page.waitForSelector('[data-testid="create-markdown-dialog"]', {
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await clickElement(page, 'add-text-type');
|
await fillInput(page, 'new-markdown-name', 'test-file_v2.md');
|
||||||
await fillInput(page, 'new-file-name', 'test-file_v2.md');
|
await fillInput(page, 'new-markdown-content', 'Content with hyphens and underscores');
|
||||||
await fillInput(page, 'new-file-content', 'Content with hyphens and underscores');
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-add-file');
|
await clickElement(page, 'confirm-create-markdown');
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
() => !document.querySelector('[data-testid="create-markdown-dialog"]'),
|
||||||
{ timeout: 5000 }
|
{ timeout: 5000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -582,18 +553,17 @@ test.describe('Context View - Edge Cases', () => {
|
|||||||
await navigateToContext(page);
|
await navigateToContext(page);
|
||||||
|
|
||||||
// Create file with empty content
|
// Create file with empty content
|
||||||
await clickElement(page, 'add-context-file');
|
await clickElement(page, 'create-markdown-button');
|
||||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
await page.waitForSelector('[data-testid="create-markdown-dialog"]', {
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await clickElement(page, 'add-text-type');
|
await fillInput(page, 'new-markdown-name', 'empty-file.md');
|
||||||
await fillInput(page, 'new-file-name', 'empty-file.md');
|
|
||||||
// Don't fill any content - leave it empty
|
// Don't fill any content - leave it empty
|
||||||
|
|
||||||
await clickElement(page, 'confirm-add-file');
|
await clickElement(page, 'confirm-create-markdown');
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
() => !document.querySelector('[data-testid="create-markdown-dialog"]'),
|
||||||
{ timeout: 5000 }
|
{ timeout: 5000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -110,9 +110,9 @@ test.describe('Kanban Responsive Scaling Tests', () => {
|
|||||||
expect(Math.abs(columnWidth - baseWidth)).toBeLessThan(2);
|
expect(Math.abs(columnWidth - baseWidth)).toBeLessThan(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Column width should be within expected bounds (280px min, 360px max)
|
// Column width should be at least minimum (280px)
|
||||||
|
// No max-width - columns scale evenly to fill available viewport
|
||||||
expect(baseWidth).toBeGreaterThanOrEqual(280);
|
expect(baseWidth).toBeGreaterThanOrEqual(280);
|
||||||
expect(baseWidth).toBeLessThanOrEqual(360);
|
|
||||||
|
|
||||||
// Columns should not overlap (check x positions)
|
// Columns should not overlap (check x positions)
|
||||||
expect(inProgressBox.x).toBeGreaterThan(backlogBox.x + backlogBox.width - 5);
|
expect(inProgressBox.x).toBeGreaterThan(backlogBox.x + backlogBox.width - 5);
|
||||||
@@ -202,4 +202,90 @@ test.describe('Kanban Responsive Scaling Tests', () => {
|
|||||||
// There should be no horizontal scroll at standard width since columns scale down
|
// There should be no horizontal scroll at standard width since columns scale down
|
||||||
expect(hasHorizontalScroll).toBe(false);
|
expect(hasHorizontalScroll).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('kanban columns should fit at minimum width (1500px - Electron minimum)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Setup project and navigate to board view
|
||||||
|
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForNetworkIdle(page);
|
||||||
|
await waitForBoardView(page);
|
||||||
|
|
||||||
|
// Set viewport to the new Electron minimum width (1500px)
|
||||||
|
await page.setViewportSize({ width: 1500, height: 900 });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Get all four kanban columns
|
||||||
|
const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]');
|
||||||
|
const verifiedColumn = page.locator('[data-testid="kanban-column-verified"]');
|
||||||
|
|
||||||
|
// Verify columns are visible
|
||||||
|
await expect(backlogColumn).toBeVisible();
|
||||||
|
await expect(verifiedColumn).toBeVisible();
|
||||||
|
|
||||||
|
// Check if horizontal scrollbar is present
|
||||||
|
const hasHorizontalScroll = await page.evaluate(() => {
|
||||||
|
const boardContainer = document.querySelector('[data-testid="board-view"]');
|
||||||
|
if (!boardContainer) return false;
|
||||||
|
return boardContainer.scrollWidth > boardContainer.clientWidth;
|
||||||
|
});
|
||||||
|
|
||||||
|
// There should be no horizontal scroll at minimum width
|
||||||
|
expect(hasHorizontalScroll).toBe(false);
|
||||||
|
|
||||||
|
// Verify columns are at least minimum width (280px)
|
||||||
|
const backlogBox = await backlogColumn.boundingBox();
|
||||||
|
expect(backlogBox).not.toBeNull();
|
||||||
|
if (backlogBox) {
|
||||||
|
expect(backlogBox.width).toBeGreaterThanOrEqual(280);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kanban columns should scale correctly when sidebar is collapsed', async ({ page }) => {
|
||||||
|
// Setup project and navigate to board view
|
||||||
|
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForNetworkIdle(page);
|
||||||
|
await waitForBoardView(page);
|
||||||
|
|
||||||
|
// Set a viewport size
|
||||||
|
await page.setViewportSize({ width: 1600, height: 900 });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Get initial column width
|
||||||
|
const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]');
|
||||||
|
const initialBox = await backlogColumn.boundingBox();
|
||||||
|
expect(initialBox).not.toBeNull();
|
||||||
|
|
||||||
|
// Find and click the sidebar collapse button
|
||||||
|
const collapseButton = page.locator('[data-testid="sidebar-collapse-button"]');
|
||||||
|
if (await collapseButton.isVisible()) {
|
||||||
|
await collapseButton.click();
|
||||||
|
|
||||||
|
// Wait for sidebar transition (300ms) + buffer
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
// Get column width after collapse
|
||||||
|
const collapsedBox = await backlogColumn.boundingBox();
|
||||||
|
expect(collapsedBox).not.toBeNull();
|
||||||
|
|
||||||
|
if (initialBox && collapsedBox) {
|
||||||
|
// Column should be wider or same after sidebar collapse (more space available)
|
||||||
|
// Allow for small variations due to transitions
|
||||||
|
expect(collapsedBox.width).toBeGreaterThanOrEqual(initialBox.width - 5);
|
||||||
|
|
||||||
|
// Width should still be at least minimum
|
||||||
|
expect(collapsedBox.width).toBeGreaterThanOrEqual(280);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no horizontal scrollbar after collapse
|
||||||
|
const hasHorizontalScroll = await page.evaluate(() => {
|
||||||
|
const boardContainer = document.querySelector('[data-testid="board-view"]');
|
||||||
|
if (!boardContainer) return false;
|
||||||
|
return boardContainer.scrollWidth > boardContainer.clientWidth;
|
||||||
|
});
|
||||||
|
expect(hasHorizontalScroll).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,19 +19,29 @@ export async function simulateFileDrop(
|
|||||||
const dataTransfer = new DataTransfer();
|
const dataTransfer = new DataTransfer();
|
||||||
dataTransfer.items.add(file);
|
dataTransfer.items.add(file);
|
||||||
|
|
||||||
|
// Create events and explicitly define the dataTransfer property
|
||||||
|
// to ensure it's accessible (some browsers don't properly set it from constructor)
|
||||||
|
const dragOverEvent = new DragEvent('dragover', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(dragOverEvent, 'dataTransfer', {
|
||||||
|
value: dataTransfer,
|
||||||
|
writable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dropEvent = new DragEvent('drop', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||||
|
value: dataTransfer,
|
||||||
|
writable: false,
|
||||||
|
});
|
||||||
|
|
||||||
// Dispatch drag events
|
// Dispatch drag events
|
||||||
target.dispatchEvent(
|
target.dispatchEvent(dragOverEvent);
|
||||||
new DragEvent('dragover', {
|
target.dispatchEvent(dropEvent);
|
||||||
dataTransfer,
|
|
||||||
bubbles: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
target.dispatchEvent(
|
|
||||||
new DragEvent('drop', {
|
|
||||||
dataTransfer,
|
|
||||||
bubbles: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
{ selector: targetSelector, content: fileContent, name: fileName, mime: mimeType }
|
{ selector: targetSelector, content: fileContent, name: fileName, mime: mimeType }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,6 +70,25 @@ export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
|||||||
/** ModelProvider - AI model provider for credentials and API key management */
|
/** ModelProvider - AI model provider for credentials and API key management */
|
||||||
export type ModelProvider = 'claude';
|
export type ModelProvider = 'claude';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WindowBounds - Electron window position and size for persistence
|
||||||
|
*
|
||||||
|
* Stored in global settings to restore window state across sessions.
|
||||||
|
* Includes position (x, y), dimensions (width, height), and maximized state.
|
||||||
|
*/
|
||||||
|
export interface WindowBounds {
|
||||||
|
/** Window X position on screen */
|
||||||
|
x: number;
|
||||||
|
/** Window Y position on screen */
|
||||||
|
y: number;
|
||||||
|
/** Window width in pixels */
|
||||||
|
width: number;
|
||||||
|
/** Window height in pixels */
|
||||||
|
height: number;
|
||||||
|
/** Whether window was maximized when closed */
|
||||||
|
isMaximized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KeyboardShortcuts - User-configurable keyboard bindings for common actions
|
* KeyboardShortcuts - User-configurable keyboard bindings for common actions
|
||||||
*
|
*
|
||||||
@@ -272,6 +291,10 @@ export interface GlobalSettings {
|
|||||||
// Session Tracking
|
// Session Tracking
|
||||||
/** Maps project path -> last selected session ID in that project */
|
/** Maps project path -> last selected session ID in that project */
|
||||||
lastSelectedSessionByProject: Record<string, string>;
|
lastSelectedSessionByProject: Record<string, string>;
|
||||||
|
|
||||||
|
// Window State (Electron only)
|
||||||
|
/** Persisted window bounds for restoring position/size across sessions */
|
||||||
|
windowBounds?: WindowBounds;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user