Rebuild of the kanban scaling logic, and adding constraints to window scaling logic for electron and web

This commit is contained in:
trueheads
2025-12-21 16:47:21 -06:00
parent ee9ccd03d6
commit 9beefd1ac3
10 changed files with 1918 additions and 266 deletions

1282
apps/app/server-bundle/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -375,6 +375,19 @@ export function Sidebar() {
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]);
// Filtered projects based on search query // Filtered projects based on search query
const filteredProjects = useMemo(() => { const filteredProjects = useMemo(() => {
if (!projectSearchQuery.trim()) { if (!projectSearchQuery.trim()) {

View File

@@ -1,8 +1,7 @@
import { memo } from 'react';
import { memo } from "react"; import { useDroppable } from '@dnd-kit/core';
import { useDroppable } from "@dnd-kit/core"; import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"; import type { ReactNode } from 'react';
import type { ReactNode } from "react";
interface KanbanColumnProps { interface KanbanColumnProps {
id: string; id: string;
@@ -39,10 +38,12 @@ 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',
!width && "w-72", // Only apply w-72 if no custom width // Only transition ring/shadow for drag-over effect, not width
showBorder && "border border-border/60", 'transition-[box-shadow,ring] duration-200',
isOver && "ring-2 ring-primary/30 ring-offset-1 ring-offset-background" !width && 'w-72', // Only apply w-72 if no custom width
showBorder && 'border border-border/60',
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background'
)} )}
style={widthStyle} style={widthStyle}
data-testid={`kanban-column-${id}`} data-testid={`kanban-column-${id}`}
@@ -50,8 +51,8 @@ export const KanbanColumn = memo(function KanbanColumn({
{/* Background layer with opacity */} {/* Background layer with opacity */}
<div <div
className={cn( className={cn(
"absolute inset-0 rounded-xl backdrop-blur-sm transition-colors duration-200", 'absolute inset-0 rounded-xl backdrop-blur-sm transition-colors duration-200',
isOver ? "bg-accent/80" : "bg-card/80" isOver ? 'bg-accent/80' : 'bg-card/80'
)} )}
style={{ opacity: opacity / 100 }} style={{ opacity: opacity / 100 }}
/> />
@@ -59,11 +60,11 @@ export const KanbanColumn = memo(function KanbanColumn({
{/* Column Header */} {/* Column Header */}
<div <div
className={cn( className={cn(
"relative z-10 flex items-center gap-3 px-3 py-2.5", 'relative z-10 flex items-center gap-3 px-3 py-2.5',
showBorder && "border-b border-border/40" showBorder && 'border-b border-border/40'
)} )}
> >
<div className={cn("w-2.5 h-2.5 rounded-full shrink-0", colorClass)} /> <div className={cn('w-2.5 h-2.5 rounded-full shrink-0', colorClass)} />
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3> <h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
{headerAction} {headerAction}
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums"> <span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
@@ -74,11 +75,11 @@ export const KanbanColumn = memo(function KanbanColumn({
{/* Column Content */} {/* Column Content */}
<div <div
className={cn( className={cn(
"relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5", 'relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5',
hideScrollbar && hideScrollbar &&
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]", '[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
// Smooth scrolling // Smooth scrolling
"scroll-smooth" 'scroll-smooth'
)} )}
> >
{children} {children}

View File

@@ -1,22 +1,15 @@
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
DndContext, import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
DragOverlay, import { Button } from '@/components/ui/button';
} from "@dnd-kit/core"; import { HotkeyButton } from '@/components/ui/hotkey-button';
import { import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
SortableContext, import { KanbanColumn, KanbanCard } from './components';
verticalListSortingStrategy, import { Feature } from '@/store/app-store';
} from "@dnd-kit/sortable"; import { FastForward, Lightbulb, Archive } from 'lucide-react';
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { Button } from "@/components/ui/button"; import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
import { HotkeyButton } from "@/components/ui/hotkey-button"; import { COLUMNS, ColumnId } from './constants';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { KanbanColumn, KanbanCard } from "./components";
import { Feature } from "@/store/app-store";
import { FastForward, Lightbulb, Archive } from "lucide-react";
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
import { useResponsiveKanban } from "@/hooks/use-responsive-kanban";
import { COLUMNS, ColumnId } from "./constants";
interface KanbanBoardProps { interface KanbanBoardProps {
sensors: any; sensors: any;
@@ -90,20 +83,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 <div className="flex-1 overflow-x-hidden px-5 pb-4 relative" style={backgroundImageStyle}>
className="flex-1 overflow-x-auto px-4 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 (
@@ -118,8 +109,7 @@ export function KanbanBoard({
showBorder={backgroundSettings.columnBorderEnabled} showBorder={backgroundSettings.columnBorderEnabled}
hideScrollbar={backgroundSettings.hideScrollbar} hideScrollbar={backgroundSettings.hideScrollbar}
headerAction={ headerAction={
column.id === "verified" && column.id === 'verified' && columnFeatures.length > 0 ? (
columnFeatures.length > 0 ? (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -130,7 +120,7 @@ export function KanbanBoard({
<Archive className="w-3 h-3 mr-1" /> <Archive className="w-3 h-3 mr-1" />
Archive All Archive All
</Button> </Button>
) : column.id === "backlog" ? ( ) : column.id === 'backlog' ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
variant="ghost" variant="ghost"
@@ -175,9 +165,8 @@ export function KanbanBoard({
{columnFeatures.map((feature, index) => { {columnFeatures.map((feature, index) => {
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0) // Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
let shortcutKey: string | undefined; let shortcutKey: string | undefined;
if (column.id === "in_progress" && index < 10) { if (column.id === 'in_progress' && index < 10) {
shortcutKey = shortcutKey = index === 9 ? '0' : String(index + 1);
index === 9 ? "0" : String(index + 1);
} }
return ( return (
<KanbanCard <KanbanCard
@@ -190,29 +179,19 @@ export function KanbanBoard({
onResume={() => onResume(feature)} onResume={() => onResume(feature)}
onForceStop={() => onForceStop(feature)} onForceStop={() => onForceStop(feature)}
onManualVerify={() => onManualVerify(feature)} onManualVerify={() => onManualVerify(feature)}
onMoveBackToInProgress={() => onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
onMoveBackToInProgress(feature)
}
onFollowUp={() => onFollowUp(feature)} onFollowUp={() => onFollowUp(feature)}
onComplete={() => onComplete(feature)} onComplete={() => onComplete(feature)}
onImplement={() => onImplement(feature)} onImplement={() => onImplement(feature)}
onViewPlan={() => onViewPlan(feature)} onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)} onApprovePlan={() => onApprovePlan(feature)}
hasContext={featuresWithContext.has(feature.id)} hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes( isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
feature.id
)}
shortcutKey={shortcutKey} shortcutKey={shortcutKey}
opacity={backgroundSettings.cardOpacity} opacity={backgroundSettings.cardOpacity}
glassmorphism={ glassmorphism={backgroundSettings.cardGlassmorphism}
backgroundSettings.cardGlassmorphism cardBorderEnabled={backgroundSettings.cardBorderEnabled}
} cardBorderOpacity={backgroundSettings.cardBorderOpacity}
cardBorderEnabled={
backgroundSettings.cardBorderEnabled
}
cardBorderOpacity={
backgroundSettings.cardBorderOpacity
}
/> />
); );
})} })}
@@ -225,7 +204,7 @@ export function KanbanBoard({
<DragOverlay <DragOverlay
dropAnimation={{ dropAnimation={{
duration: 200, duration: 200,
easing: "cubic-bezier(0.18, 0.67, 0.6, 1.22)", easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
}} }}
> >
{activeFeature && ( {activeFeature && (

View File

@@ -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;
@@ -16,13 +17,18 @@ const DEFAULT_CONFIG: ResponsiveKanbanConfig = {
columnMinWidth: 280, // Minimum column width - increased to ensure usability columnMinWidth: 280, // Minimum column width - increased to ensure usability
columnMaxWidth: 360, // Maximum column width to ensure responsive scaling columnMaxWidth: 360, // Maximum column width to ensure responsive scaling
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,82 +54,141 @@ 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 return DEFAULT_CONFIG.columnWidth;
? boardContainer.clientWidth }
: window.innerWidth;
// 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));
const [columnWidth, setColumnWidth] = useState<number>(() => return idealWidth;
calculateColumnWidth() },
[columnCount, columnMinWidth, columnMaxWidth, gap, padding]
); );
useEffect(() => { const [columnWidth, setColumnWidth] = useState<number>(() => calculateColumnWidth());
if (typeof window === "undefined") return;
const handleResize = () => { // 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(() => {
if (typeof window === 'undefined') return;
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`,
height: "100%", height: '100%',
justifyContent: "center", justifyContent: 'center',
}; };
return { return {
columnWidth, columnWidth,
containerStyle, containerStyle,
isCompact, isCompact,
totalBoardWidth,
isInitialized,
}; };
} }

View File

@@ -5,11 +5,11 @@
* Only native features (dialogs, shell) use IPC. * Only native features (dialogs, shell) use IPC.
*/ */
import path from "path"; 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;
@@ -19,9 +19,9 @@ const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
if (isDev) { if (isDev) {
try { try {
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
require("dotenv").config({ path: path.join(__dirname, "../.env") }); require('dotenv').config({ path: path.join(__dirname, '../.env') });
} catch (error) { } catch (error) {
console.warn("[Electron] dotenv not available:", (error as Error).message); console.warn('[Electron] dotenv not available:', (error as Error).message);
} }
} }
@@ -31,22 +31,55 @@ 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 = 850; // 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
*/ */
function getIconPath(): string | null { function getIconPath(): string | null {
let iconFile: string; let iconFile: string;
if (process.platform === "win32") { if (process.platform === 'win32') {
iconFile = "icon.ico"; iconFile = 'icon.ico';
} else if (process.platform === "darwin") { } else if (process.platform === 'darwin') {
iconFile = "logo_larger.png"; iconFile = 'logo_larger.png';
} else { } else {
iconFile = "logo_larger.png"; iconFile = 'logo_larger.png';
} }
const iconPath = isDev const iconPath = isDev
? path.join(__dirname, "../public", iconFile) ? path.join(__dirname, '../public', iconFile)
: path.join(__dirname, "../dist/public", iconFile); : path.join(__dirname, '../dist/public', iconFile);
if (!fs.existsSync(iconPath)) { if (!fs.existsSync(iconPath)) {
console.warn(`[Electron] Icon not found at: ${iconPath}`); console.warn(`[Electron] Icon not found at: ${iconPath}`);
@@ -56,22 +89,136 @@ 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
*/ */
async function startStaticServer(): Promise<void> { async function startStaticServer(): Promise<void> {
const staticPath = path.join(__dirname, "../dist"); const staticPath = path.join(__dirname, '../dist');
staticServer = http.createServer((request, response) => { staticServer = http.createServer((request, response) => {
let filePath = path.join(staticPath, request.url?.split("?")[0] || "/"); let filePath = path.join(staticPath, request.url?.split('?')[0] || '/');
if (filePath.endsWith("/")) { if (filePath.endsWith('/')) {
filePath = path.join(filePath, "index.html"); filePath = path.join(filePath, 'index.html');
} else if (!path.extname(filePath)) { } else if (!path.extname(filePath)) {
// For client-side routing, serve index.html for paths without extensions // For client-side routing, serve index.html for paths without extensions
const possibleFile = filePath + ".html"; const possibleFile = filePath + '.html';
if (!fs.existsSync(filePath) && !fs.existsSync(possibleFile)) { if (!fs.existsSync(filePath) && !fs.existsSync(possibleFile)) {
filePath = path.join(staticPath, "index.html"); filePath = path.join(staticPath, 'index.html');
} else if (fs.existsSync(possibleFile)) { } else if (fs.existsSync(possibleFile)) {
filePath = possibleFile; filePath = possibleFile;
} }
@@ -79,35 +226,35 @@ async function startStaticServer(): Promise<void> {
fs.stat(filePath, (err, stats) => { fs.stat(filePath, (err, stats) => {
if (err || !stats?.isFile()) { if (err || !stats?.isFile()) {
filePath = path.join(staticPath, "index.html"); filePath = path.join(staticPath, 'index.html');
} }
fs.readFile(filePath, (error, content) => { fs.readFile(filePath, (error, content) => {
if (error) { if (error) {
response.writeHead(500); response.writeHead(500);
response.end("Server Error"); response.end('Server Error');
return; return;
} }
const ext = path.extname(filePath); const ext = path.extname(filePath);
const contentTypes: Record<string, string> = { const contentTypes: Record<string, string> = {
".html": "text/html", '.html': 'text/html',
".js": "application/javascript", '.js': 'application/javascript',
".css": "text/css", '.css': 'text/css',
".json": "application/json", '.json': 'application/json',
".png": "image/png", '.png': 'image/png',
".jpg": "image/jpeg", '.jpg': 'image/jpeg',
".gif": "image/gif", '.gif': 'image/gif',
".svg": "image/svg+xml", '.svg': 'image/svg+xml',
".ico": "image/x-icon", '.ico': 'image/x-icon',
".woff": "font/woff", '.woff': 'font/woff',
".woff2": "font/woff2", '.woff2': 'font/woff2',
".ttf": "font/ttf", '.ttf': 'font/ttf',
".eot": "application/vnd.ms-fontobject", '.eot': 'application/vnd.ms-fontobject',
}; };
response.writeHead(200, { response.writeHead(200, {
"Content-Type": contentTypes[ext] || "application/octet-stream", 'Content-Type': contentTypes[ext] || 'application/octet-stream',
}); });
response.end(content); response.end(content);
}); });
@@ -116,12 +263,10 @@ async function startStaticServer(): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
staticServer!.listen(STATIC_PORT, () => { staticServer!.listen(STATIC_PORT, () => {
console.log( console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`);
`[Electron] Static server running at http://localhost:${STATIC_PORT}`
);
resolve(); resolve();
}); });
staticServer!.on("error", reject); staticServer!.on('error', reject);
}); });
} }
@@ -134,36 +279,31 @@ async function startServer(): Promise<void> {
let serverPath: string; let serverPath: string;
if (isDev) { if (isDev) {
command = "node"; command = 'node';
serverPath = path.join(__dirname, "../../server/src/index.ts"); serverPath = path.join(__dirname, '../../server/src/index.ts');
const serverNodeModules = path.join( const serverNodeModules = path.join(__dirname, '../../server/node_modules/tsx');
__dirname, const rootNodeModules = path.join(__dirname, '../../../node_modules/tsx');
"../../server/node_modules/tsx"
);
const rootNodeModules = path.join(__dirname, "../../../node_modules/tsx");
let tsxCliPath: string; let tsxCliPath: string;
if (fs.existsSync(path.join(serverNodeModules, "dist/cli.mjs"))) { if (fs.existsSync(path.join(serverNodeModules, 'dist/cli.mjs'))) {
tsxCliPath = path.join(serverNodeModules, "dist/cli.mjs"); tsxCliPath = path.join(serverNodeModules, 'dist/cli.mjs');
} else if (fs.existsSync(path.join(rootNodeModules, "dist/cli.mjs"))) { } else if (fs.existsSync(path.join(rootNodeModules, 'dist/cli.mjs'))) {
tsxCliPath = path.join(rootNodeModules, "dist/cli.mjs"); tsxCliPath = path.join(rootNodeModules, 'dist/cli.mjs');
} else { } else {
try { try {
tsxCliPath = require.resolve("tsx/cli.mjs", { tsxCliPath = require.resolve('tsx/cli.mjs', {
paths: [path.join(__dirname, "../../server")], paths: [path.join(__dirname, '../../server')],
}); });
} catch { } catch {
throw new Error( throw new Error("Could not find tsx. Please run 'npm install' in the server directory.");
"Could not find tsx. Please run 'npm install' in the server directory."
);
} }
} }
args = [tsxCliPath, "watch", serverPath]; args = [tsxCliPath, 'watch', serverPath];
} else { } else {
command = "node"; command = 'node';
serverPath = path.join(process.resourcesPath, "server", "index.js"); serverPath = path.join(process.resourcesPath, 'server', 'index.js');
args = [serverPath]; args = [serverPath];
if (!fs.existsSync(serverPath)) { if (!fs.existsSync(serverPath)) {
@@ -172,13 +312,13 @@ async function startServer(): Promise<void> {
} }
const serverNodeModules = app.isPackaged const serverNodeModules = app.isPackaged
? path.join(process.resourcesPath, "server", "node_modules") ? path.join(process.resourcesPath, 'server', 'node_modules')
: path.join(__dirname, "../../server/node_modules"); : path.join(__dirname, '../../server/node_modules');
const env = { const env = {
...process.env, ...process.env,
PORT: SERVER_PORT.toString(), PORT: SERVER_PORT.toString(),
DATA_DIR: app.getPath("userData"), DATA_DIR: app.getPath('userData'),
NODE_PATH: serverNodeModules, NODE_PATH: serverNodeModules,
// Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment // Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment
// If not set, server will allow access to all paths // If not set, server will allow access to all paths
@@ -187,30 +327,30 @@ async function startServer(): Promise<void> {
}), }),
}; };
console.log("[Electron] Starting backend server..."); console.log('[Electron] Starting backend server...');
console.log("[Electron] Server path:", serverPath); console.log('[Electron] Server path:', serverPath);
console.log("[Electron] NODE_PATH:", serverNodeModules); console.log('[Electron] NODE_PATH:', serverNodeModules);
serverProcess = spawn(command, args, { serverProcess = spawn(command, args, {
cwd: path.dirname(serverPath), cwd: path.dirname(serverPath),
env, env,
stdio: ["ignore", "pipe", "pipe"], stdio: ['ignore', 'pipe', 'pipe'],
}); });
serverProcess.stdout?.on("data", (data) => { serverProcess.stdout?.on('data', (data) => {
console.log(`[Server] ${data.toString().trim()}`); console.log(`[Server] ${data.toString().trim()}`);
}); });
serverProcess.stderr?.on("data", (data) => { serverProcess.stderr?.on('data', (data) => {
console.error(`[Server Error] ${data.toString().trim()}`); console.error(`[Server Error] ${data.toString().trim()}`);
}); });
serverProcess.on("close", (code) => { serverProcess.on('close', (code) => {
console.log(`[Server] Process exited with code ${code}`); console.log(`[Server] Process exited with code ${code}`);
serverProcess = null; serverProcess = null;
}); });
serverProcess.on("error", (err) => { serverProcess.on('error', (err) => {
console.error(`[Server] Failed to start server process:`, err); console.error(`[Server] Failed to start server process:`, err);
serverProcess = null; serverProcess = null;
}); });
@@ -225,30 +365,27 @@ async function waitForServer(maxAttempts = 30): Promise<void> {
for (let i = 0; i < maxAttempts; i++) { for (let i = 0; i < maxAttempts; i++) {
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const req = http.get( const req = http.get(`http://localhost:${SERVER_PORT}/api/health`, (res) => {
`http://localhost:${SERVER_PORT}/api/health`, if (res.statusCode === 200) {
(res) => { resolve();
if (res.statusCode === 200) { } else {
resolve(); reject(new Error(`Status: ${res.statusCode}`));
} else {
reject(new Error(`Status: ${res.statusCode}`));
}
} }
); });
req.on("error", reject); req.on('error', reject);
req.setTimeout(1000, () => { req.setTimeout(1000, () => {
req.destroy(); req.destroy();
reject(new Error("Timeout")); reject(new Error('Timeout'));
}); });
}); });
console.log("[Electron] Server is ready"); console.log('[Electron] Server is ready');
return; return;
} catch { } catch {
await new Promise((r) => setTimeout(r, 500)); await new Promise((r) => setTimeout(r, 500));
} }
} }
throw new Error("Server failed to start"); throw new Error('Server failed to start');
} }
/** /**
@@ -256,18 +393,25 @@ 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,
nodeIntegration: false, nodeIntegration: false,
}, },
titleBarStyle: "hiddenInset", titleBarStyle: 'hiddenInset',
backgroundColor: "#0a0a0a", backgroundColor: '#0a0a0a',
}; };
if (iconPath) { if (iconPath) {
@@ -276,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);
@@ -286,17 +435,42 @@ function createWindow(): void {
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`); mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
} }
if (isDev && process.env.OPEN_DEVTOOLS === "true") { if (isDev && process.env.OPEN_DEVTOOLS === 'true') {
mainWindow.webContents.openDevTools(); mainWindow.webContents.openDevTools();
} }
mainWindow.on("closed", () => { // 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 = 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' };
}); });
} }
@@ -304,28 +478,22 @@ function createWindow(): void {
app.whenReady().then(async () => { app.whenReady().then(async () => {
// Ensure userData path is consistent across dev/prod so files land in Automaker dir // Ensure userData path is consistent across dev/prod so files land in Automaker dir
try { try {
const desiredUserDataPath = path.join(app.getPath("appData"), "Automaker"); const desiredUserDataPath = path.join(app.getPath('appData'), 'Automaker');
if (app.getPath("userData") !== desiredUserDataPath) { if (app.getPath('userData') !== desiredUserDataPath) {
app.setPath("userData", desiredUserDataPath); app.setPath('userData', desiredUserDataPath);
console.log("[Electron] userData path set to:", desiredUserDataPath); console.log('[Electron] userData path set to:', desiredUserDataPath);
} }
} catch (error) { } catch (error) {
console.warn( console.warn('[Electron] Failed to set userData path:', (error as Error).message);
"[Electron] Failed to set userData path:",
(error as Error).message
);
} }
if (process.platform === "darwin" && app.dock) { if (process.platform === 'darwin' && app.dock) {
const iconPath = getIconPath(); const iconPath = getIconPath();
if (iconPath) { if (iconPath) {
try { try {
app.dock.setIcon(iconPath); app.dock.setIcon(iconPath);
} catch (error) { } catch (error) {
console.warn( console.warn('[Electron] Failed to set dock icon:', (error as Error).message);
"[Electron] Failed to set dock icon:",
(error as Error).message
);
} }
} }
} }
@@ -342,32 +510,32 @@ app.whenReady().then(async () => {
// Create window // Create window
createWindow(); createWindow();
} catch (error) { } catch (error) {
console.error("[Electron] Failed to start:", error); console.error('[Electron] Failed to start:', error);
app.quit(); app.quit();
} }
app.on("activate", () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
createWindow(); createWindow();
} }
}); });
}); });
app.on("window-all-closed", () => { app.on('window-all-closed', () => {
if (process.platform !== "darwin") { if (process.platform !== 'darwin') {
app.quit(); app.quit();
} }
}); });
app.on("before-quit", () => { app.on('before-quit', () => {
if (serverProcess) { if (serverProcess) {
console.log("[Electron] Stopping server..."); console.log('[Electron] Stopping server...');
serverProcess.kill(); serverProcess.kill();
serverProcess = null; serverProcess = null;
} }
if (staticServer) { if (staticServer) {
console.log("[Electron] Stopping static server..."); console.log('[Electron] Stopping static server...');
staticServer.close(); staticServer.close();
staticServer = null; staticServer = null;
} }
@@ -378,28 +546,28 @@ app.on("before-quit", () => {
// ============================================ // ============================================
// Native file dialogs // Native file dialogs
ipcMain.handle("dialog:openDirectory", async () => { ipcMain.handle('dialog:openDirectory', async () => {
if (!mainWindow) { if (!mainWindow) {
return { canceled: true, filePaths: [] }; return { canceled: true, filePaths: [] };
} }
const result = await dialog.showOpenDialog(mainWindow, { const result = await dialog.showOpenDialog(mainWindow, {
properties: ["openDirectory", "createDirectory"], properties: ['openDirectory', 'createDirectory'],
}); });
return result; return result;
}); });
ipcMain.handle("dialog:openFile", async (_, options = {}) => { ipcMain.handle('dialog:openFile', async (_, options = {}) => {
if (!mainWindow) { if (!mainWindow) {
return { canceled: true, filePaths: [] }; return { canceled: true, filePaths: [] };
} }
const result = await dialog.showOpenDialog(mainWindow, { const result = await dialog.showOpenDialog(mainWindow, {
properties: ["openFile"], properties: ['openFile'],
...options, ...options,
}); });
return result; return result;
}); });
ipcMain.handle("dialog:saveFile", async (_, options = {}) => { ipcMain.handle('dialog:saveFile', async (_, options = {}) => {
if (!mainWindow) { if (!mainWindow) {
return { canceled: true, filePath: undefined }; return { canceled: true, filePath: undefined };
} }
@@ -408,7 +576,7 @@ ipcMain.handle("dialog:saveFile", async (_, options = {}) => {
}); });
// Shell operations // Shell operations
ipcMain.handle("shell:openExternal", async (_, url: string) => { ipcMain.handle('shell:openExternal', async (_, url: string) => {
try { try {
await shell.openExternal(url); await shell.openExternal(url);
return { success: true }; return { success: true };
@@ -417,7 +585,7 @@ ipcMain.handle("shell:openExternal", async (_, url: string) => {
} }
}); });
ipcMain.handle("shell:openPath", async (_, filePath: string) => { ipcMain.handle('shell:openPath', async (_, filePath: string) => {
try { try {
await shell.openPath(filePath); await shell.openPath(filePath);
return { success: true }; return { success: true };
@@ -427,27 +595,38 @@ ipcMain.handle("shell:openPath", async (_, filePath: string) => {
}); });
// App info // App info
ipcMain.handle( ipcMain.handle('app:getPath', async (_, name: Parameters<typeof app.getPath>[0]) => {
"app:getPath", return app.getPath(name);
async (_, name: Parameters<typeof app.getPath>[0]) => { });
return app.getPath(name);
}
);
ipcMain.handle("app:getVersion", async () => { ipcMain.handle('app:getVersion', async () => {
return app.getVersion(); return app.getVersion();
}); });
ipcMain.handle("app:isPackaged", async () => { ipcMain.handle('app:isPackaged', async () => {
return app.isPackaged; return app.isPackaged;
}); });
// Ping - for connection check // Ping - for connection check
ipcMain.handle("ping", async () => { ipcMain.handle('ping', async () => {
return "pong"; return 'pong';
}); });
// Get server URL for HTTP client // Get server URL for HTTP client
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);
}
});

View File

@@ -5,38 +5,42 @@
* All other operations go through HTTP API. * All other operations go through HTTP API.
*/ */
import { contextBridge, ipcRenderer, OpenDialogOptions, SaveDialogOptions } from "electron"; import { contextBridge, ipcRenderer, OpenDialogOptions, SaveDialogOptions } from 'electron';
// Expose minimal API for native features // Expose minimal API for native features
contextBridge.exposeInMainWorld("electronAPI", { contextBridge.exposeInMainWorld('electronAPI', {
// Platform info // Platform info
platform: process.platform, platform: process.platform,
isElectron: true, isElectron: true,
// Connection check // Connection check
ping: (): Promise<string> => ipcRenderer.invoke("ping"), ping: (): Promise<string> => ipcRenderer.invoke('ping'),
// Get server URL for HTTP client // Get server URL for HTTP client
getServerUrl: (): Promise<string> => ipcRenderer.invoke("server:getUrl"), getServerUrl: (): Promise<string> => ipcRenderer.invoke('server:getUrl'),
// Native dialogs - better UX than prompt() // Native dialogs - better UX than prompt()
openDirectory: (): Promise<Electron.OpenDialogReturnValue> => openDirectory: (): Promise<Electron.OpenDialogReturnValue> =>
ipcRenderer.invoke("dialog:openDirectory"), ipcRenderer.invoke('dialog:openDirectory'),
openFile: (options?: OpenDialogOptions): Promise<Electron.OpenDialogReturnValue> => openFile: (options?: OpenDialogOptions): Promise<Electron.OpenDialogReturnValue> =>
ipcRenderer.invoke("dialog:openFile", options), ipcRenderer.invoke('dialog:openFile', options),
saveFile: (options?: SaveDialogOptions): Promise<Electron.SaveDialogReturnValue> => saveFile: (options?: SaveDialogOptions): Promise<Electron.SaveDialogReturnValue> =>
ipcRenderer.invoke("dialog:saveFile", options), ipcRenderer.invoke('dialog:saveFile', options),
// Shell operations // Shell operations
openExternalLink: (url: string): Promise<{ success: boolean; error?: string }> => openExternalLink: (url: string): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke("shell:openExternal", url), ipcRenderer.invoke('shell:openExternal', url),
openPath: (filePath: string): Promise<{ success: boolean; error?: string }> => openPath: (filePath: string): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke("shell:openPath", filePath), ipcRenderer.invoke('shell:openPath', filePath),
// App info // App info
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)');

View File

@@ -5,8 +5,8 @@
* the available window space without dead space or content being cut off. * the available window space without dead space or content being cut off.
*/ */
import { test, expect } from "@playwright/test"; import { test, expect } from '@playwright/test';
import * as fs from "fs"; import * as fs from 'fs';
import { import {
waitForNetworkIdle, waitForNetworkIdle,
@@ -15,17 +15,17 @@ import {
createTempDirPath, createTempDirPath,
setupProjectWithPathNoWorktrees, setupProjectWithPathNoWorktrees,
waitForBoardView, waitForBoardView,
} from "./utils"; } from './utils';
// Create unique temp dir for this test run // Create unique temp dir for this test run
const TEST_TEMP_DIR = createTempDirPath("kanban-responsive-tests"); const TEST_TEMP_DIR = createTempDirPath('kanban-responsive-tests');
interface TestRepo { interface TestRepo {
path: string; path: string;
cleanup: () => Promise<void>; cleanup: () => Promise<void>;
} }
test.describe("Kanban Responsive Scaling Tests", () => { test.describe('Kanban Responsive Scaling Tests', () => {
let testRepo: TestRepo; let testRepo: TestRepo;
test.beforeAll(async () => { test.beforeAll(async () => {
@@ -52,12 +52,12 @@ test.describe("Kanban Responsive Scaling Tests", () => {
cleanupTempDir(TEST_TEMP_DIR); cleanupTempDir(TEST_TEMP_DIR);
}); });
test("kanban columns should scale to fill available width at different viewport sizes", async ({ test('kanban columns should scale to fill available width at different viewport sizes', async ({
page, page,
}) => { }) => {
// Setup project and navigate to board view // Setup project and navigate to board view
await setupProjectWithPathNoWorktrees(page, testRepo.path); await setupProjectWithPathNoWorktrees(page, testRepo.path);
await page.goto("/"); await page.goto('/');
await waitForNetworkIdle(page); await waitForNetworkIdle(page);
await waitForBoardView(page); await waitForBoardView(page);
@@ -122,12 +122,10 @@ test.describe("Kanban Responsive Scaling Tests", () => {
} }
}); });
test("kanban columns should be centered in the viewport", async ({ test('kanban columns should be centered in the viewport', async ({ page }) => {
page,
}) => {
// Setup project and navigate to board view // Setup project and navigate to board view
await setupProjectWithPathNoWorktrees(page, testRepo.path); await setupProjectWithPathNoWorktrees(page, testRepo.path);
await page.goto("/"); await page.goto('/');
await waitForNetworkIdle(page); await waitForNetworkIdle(page);
await waitForBoardView(page); await waitForBoardView(page);
@@ -181,12 +179,12 @@ test.describe("Kanban Responsive Scaling Tests", () => {
} }
}); });
test("kanban columns should have no horizontal scrollbar at standard viewport width", async ({ test('kanban columns should have no horizontal scrollbar at standard viewport width', async ({
page, page,
}) => { }) => {
// Setup project and navigate to board view // Setup project and navigate to board view
await setupProjectWithPathNoWorktrees(page, testRepo.path); await setupProjectWithPathNoWorktrees(page, testRepo.path);
await page.goto("/"); await page.goto('/');
await waitForNetworkIdle(page); await waitForNetworkIdle(page);
await waitForBoardView(page); await waitForBoardView(page);
@@ -204,4 +202,92 @@ 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 minimum width (280px)
const backlogBox = await backlogColumn.boundingBox();
expect(backlogBox).not.toBeNull();
if (backlogBox) {
expect(backlogBox.width).toBeGreaterThanOrEqual(280);
expect(backlogBox.width).toBeLessThanOrEqual(360);
}
});
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 within bounds
expect(collapsedBox.width).toBeGreaterThanOrEqual(280);
expect(collapsedBox.width).toBeLessThanOrEqual(360);
}
// 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);
}
});
}); });

View File

@@ -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;
} }
/** /**