fix: adress pr comments

This commit is contained in:
Shirone
2026-01-12 22:03:14 +01:00
parent 1f270edbe1
commit 029c5ca855
10 changed files with 153 additions and 74 deletions

View File

@@ -22,6 +22,34 @@ import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('GenerateCommitMessage'); const logger = createLogger('GenerateCommitMessage');
const execAsync = promisify(exec); const execAsync = promisify(exec);
/** Timeout for AI provider calls in milliseconds (30 seconds) */
const AI_TIMEOUT_MS = 30_000;
/**
* Wraps an async generator with a timeout.
* If the generator takes longer than the timeout, it throws an error.
*/
async function* withTimeout<T>(
generator: AsyncIterable<T>,
timeoutMs: number
): AsyncGenerator<T, void, unknown> {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)), timeoutMs);
});
const iterator = generator[Symbol.asyncIterator]();
let done = false;
while (!done) {
const result = await Promise.race([iterator.next(), timeoutPromise]);
if (result.done) {
done = true;
} else {
yield result.value;
}
}
}
/** /**
* Get the effective system prompt for commit message generation. * Get the effective system prompt for commit message generation.
* Uses custom prompt from settings if enabled, otherwise falls back to default. * Uses custom prompt from settings if enabled, otherwise falls back to default.
@@ -180,14 +208,17 @@ export function createGenerateCommitMessageHandler(
const cursorPrompt = `${systemPrompt}\n\n${userPrompt}`; const cursorPrompt = `${systemPrompt}\n\n${userPrompt}`;
let responseText = ''; let responseText = '';
for await (const msg of provider.executeQuery({ const cursorStream = provider.executeQuery({
prompt: cursorPrompt, prompt: cursorPrompt,
model: bareModel, model: bareModel,
cwd: worktreePath, cwd: worktreePath,
maxTurns: 1, maxTurns: 1,
allowedTools: [], allowedTools: [],
readOnly: true, readOnly: true,
})) { });
// Wrap with timeout to prevent indefinite hangs
for await (const msg of withTimeout(cursorStream, AI_TIMEOUT_MS)) {
if (msg.type === 'assistant' && msg.message?.content) { if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) { for (const block of msg.message.content) {
if (block.type === 'text' && block.text) { if (block.type === 'text' && block.text) {
@@ -211,7 +242,8 @@ export function createGenerateCommitMessageHandler(
}, },
}); });
message = await extractTextFromStream(stream); // Wrap with timeout to prevent indefinite hangs
message = await extractTextFromStream(withTimeout(stream, AI_TIMEOUT_MS));
} }
if (!message || message.trim().length === 0) { if (!message || message.trim().length === 0) {

View File

@@ -16,6 +16,9 @@ import {
import { NoProjectState, AgentHeader, ChatArea } from './agent-view/components'; import { NoProjectState, AgentHeader, ChatArea } from './agent-view/components';
import { AgentInputArea } from './agent-view/input-area'; import { AgentInputArea } from './agent-view/input-area';
/** Tailwind lg breakpoint in pixels */
const LG_BREAKPOINT = 1024;
export function AgentView() { export function AgentView() {
const { currentProject } = useAppStore(); const { currentProject } = useAppStore();
const [input, setInput] = useState(''); const [input, setInput] = useState('');
@@ -24,12 +27,21 @@ export function AgentView() {
// Then updates on mount based on actual screen size to prevent hydration mismatch // Then updates on mount based on actual screen size to prevent hydration mismatch
const [showSessionManager, setShowSessionManager] = useState(true); const [showSessionManager, setShowSessionManager] = useState(true);
// Update session manager visibility based on screen size after mount // Update session manager visibility based on screen size after mount and on resize
useEffect(() => { useEffect(() => {
// Check if we're on a mobile screen (< lg breakpoint = 1024px) const updateVisibility = () => {
const isDesktop = window.innerWidth >= 1024; const isDesktop = window.innerWidth >= LG_BREAKPOINT;
setShowSessionManager(isDesktop); setShowSessionManager(isDesktop);
};
// Set initial value
updateVisibility();
// Listen for resize events
window.addEventListener('resize', updateVisibility);
return () => window.removeEventListener('resize', updateVisibility);
}, []); }, []);
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'sonnet' }); const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'sonnet' });
// Input ref for auto-focus // Input ref for auto-focus

View File

@@ -1320,7 +1320,7 @@ export function BoardView() {
handleViewOutput(feature); handleViewOutput(feature);
} }
}} }}
className="transition-opacity duration-250" className="transition-opacity duration-200"
/> />
) : ( ) : (
<KanbanBoard <KanbanBoard
@@ -1362,7 +1362,7 @@ export function BoardView() {
viewMode={viewMode} viewMode={viewMode}
isDragging={activeFeature !== null} isDragging={activeFeature !== null}
onAiSuggest={() => setShowPlanDialog(true)} onAiSuggest={() => setShowPlanDialog(true)}
className="transition-opacity duration-250" className="transition-opacity duration-200"
/> />
)} )}
</div> </div>

View File

@@ -1,7 +1,7 @@
// TODO: Remove @ts-nocheck after fixing BaseFeature's index signature issue // TODO: Remove @ts-nocheck after fixing BaseFeature's index signature issue
// The `[key: string]: unknown` in BaseFeature causes property access type errors // The `[key: string]: unknown` in BaseFeature causes property access type errors
// @ts-nocheck // @ts-nocheck
import { memo, useCallback, useMemo } from 'react'; import { memo, useCallback, useState, useEffect } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { AlertCircle, Lock, Hand, Sparkles, FileText } from 'lucide-react'; import { AlertCircle, Lock, Hand, Sparkles, FileText } from 'lucide-react';
@@ -49,14 +49,34 @@ const IndicatorBadges = memo(function IndicatorBadges({
feature.skipTests && !feature.error && feature.status === 'backlog'; feature.skipTests && !feature.error && feature.status === 'backlog';
const hasPlan = feature.planSpec?.content; const hasPlan = feature.planSpec?.content;
// Check if just finished (within 2 minutes) // Check if just finished (within 2 minutes) - uses timer to auto-expire
const isJustFinished = useMemo(() => { const [isJustFinished, setIsJustFinished] = useState(false);
useEffect(() => {
if (!feature.justFinishedAt || feature.status !== 'waiting_approval' || feature.error) { if (!feature.justFinishedAt || feature.status !== 'waiting_approval' || feature.error) {
return false; setIsJustFinished(false);
return;
} }
const finishedTime = new Date(feature.justFinishedAt).getTime(); const finishedTime = new Date(feature.justFinishedAt).getTime();
const twoMinutes = 2 * 60 * 1000; const twoMinutes = 2 * 60 * 1000;
return Date.now() - finishedTime < twoMinutes; const elapsed = Date.now() - finishedTime;
if (elapsed >= twoMinutes) {
setIsJustFinished(false);
return;
}
// Set as just finished
setIsJustFinished(true);
// Set a timeout to clear the "just finished" state when 2 minutes have passed
const remainingTime = twoMinutes - elapsed;
const timeoutId = setTimeout(() => {
setIsJustFinished(false);
}, remainingTime);
return () => clearTimeout(timeoutId);
}, [feature.justFinishedAt, feature.status, feature.error]); }, [feature.justFinishedAt, feature.status, feature.error]);
const badges: Array<{ const badges: Array<{

View File

@@ -12,6 +12,9 @@ import { getStatusLabel, getStatusOrder } from './status-badge';
import { getColumnsWithPipeline } from '../../constants'; import { getColumnsWithPipeline } from '../../constants';
import type { SortConfig, SortColumn } from '../../hooks/use-list-view-state'; import type { SortConfig, SortColumn } from '../../hooks/use-list-view-state';
/** Empty set constant to avoid creating new instances on each render */
const EMPTY_SET = new Set<string>();
/** /**
* Status group configuration for the list view * Status group configuration for the list view
*/ */
@@ -184,7 +187,7 @@ export const ListView = memo(function ListView({
pipelineConfig = null, pipelineConfig = null,
onAddFeature, onAddFeature,
isSelectionMode = false, isSelectionMode = false,
selectedFeatureIds = new Set(), selectedFeatureIds = EMPTY_SET,
onToggleFeatureSelection, onToggleFeatureSelection,
onRowClick, onRowClick,
className, className,

View File

@@ -80,7 +80,8 @@ export function CommitWorktreeDialog({
}; };
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && e.metaKey && !isLoading && message.trim()) { // Prevent commit while loading or while AI is generating a message
if (e.key === 'Enter' && e.metaKey && !isLoading && !isGenerating && message.trim()) {
handleCommit(); handleCommit();
} }
}; };

View File

@@ -114,7 +114,7 @@ export function KanbanBoard({
<div <div
className={cn( className={cn(
'flex-1 overflow-x-auto px-5 pt-4 pb-4 relative', 'flex-1 overflow-x-auto px-5 pt-4 pb-4 relative',
'transition-opacity duration-250', 'transition-opacity duration-200',
className className
)} )}
style={backgroundImageStyle} style={backgroundImageStyle}

View File

@@ -1,9 +1,8 @@
import { useEffect, useCallback, useState } from 'react'; import { useEffect, useCallback, useState, type ComponentType, type ReactNode } from 'react';
import { RefreshCw, AlertTriangle } from 'lucide-react'; import { RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { useAppStore, type ClaudeUsage, type CodexUsage } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon'; import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
interface MobileUsageBarProps { interface MobileUsageBarProps {
@@ -70,11 +69,11 @@ function UsageItem({
onRefresh, onRefresh,
children, children,
}: { }: {
icon: React.ComponentType<{ className?: string }>; icon: ComponentType<{ className?: string }>;
label: string; label: string;
isLoading: boolean; isLoading: boolean;
onRefresh: () => void; onRefresh: () => void;
children: React.ReactNode; children: ReactNode;
}) { }) {
return ( return (
<div className="px-2 py-2"> <div className="px-2 py-2">

View File

@@ -26,6 +26,58 @@ const USAGE_COLOR_CRITICAL = 'bg-red-500';
const USAGE_COLOR_WARNING = 'bg-amber-500'; const USAGE_COLOR_WARNING = 'bg-amber-500';
const USAGE_COLOR_OK = 'bg-indigo-500'; const USAGE_COLOR_OK = 'bg-indigo-500';
/**
* Get the appropriate color class for a usage percentage
*/
function getUsageColor(percentage: number): string {
if (percentage >= WARNING_THRESHOLD) {
return USAGE_COLOR_CRITICAL;
}
if (percentage >= CAUTION_THRESHOLD) {
return USAGE_COLOR_WARNING;
}
return USAGE_COLOR_OK;
}
/**
* Individual usage card displaying a usage metric with progress bar
*/
function UsageCard({
title,
subtitle,
percentage,
resetText,
}: {
title: string;
subtitle: string;
percentage: number;
resetText?: string;
}) {
const safePercentage = Math.min(Math.max(percentage, 0), MAX_PERCENTAGE);
return (
<div className="rounded-xl border border-border/60 bg-card/50 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-foreground">{title}</p>
<p className="text-xs text-muted-foreground">{subtitle}</p>
</div>
<span className="text-sm font-semibold text-foreground">{Math.round(safePercentage)}%</span>
</div>
<div className="mt-3 h-2 w-full rounded-full bg-secondary/60">
<div
className={cn(
'h-full rounded-full transition-all duration-300',
getUsageColor(safePercentage)
)}
style={{ width: `${safePercentage}%` }}
/>
</div>
{resetText && <p className="mt-2 text-xs text-muted-foreground">{resetText}</p>}
</div>
);
}
export function ClaudeUsageSection() { export function ClaudeUsageSection() {
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore(); const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
@@ -100,54 +152,6 @@ export function ClaudeUsageSection() {
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, [fetchUsage, canFetchUsage]); }, [fetchUsage, canFetchUsage]);
const getUsageColor = (percentage: number) => {
if (percentage >= WARNING_THRESHOLD) {
return USAGE_COLOR_CRITICAL;
}
if (percentage >= CAUTION_THRESHOLD) {
return USAGE_COLOR_WARNING;
}
return USAGE_COLOR_OK;
};
const UsageCard = ({
title,
subtitle,
percentage,
resetText,
}: {
title: string;
subtitle: string;
percentage: number;
resetText?: string;
}) => {
const safePercentage = Math.min(Math.max(percentage, 0), MAX_PERCENTAGE);
return (
<div className="rounded-xl border border-border/60 bg-card/50 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-foreground">{title}</p>
<p className="text-xs text-muted-foreground">{subtitle}</p>
</div>
<span className="text-sm font-semibold text-foreground">
{Math.round(safePercentage)}%
</span>
</div>
<div className="mt-3 h-2 w-full rounded-full bg-secondary/60">
<div
className={cn(
'h-full rounded-full transition-all duration-300',
getUsageColor(safePercentage)
)}
style={{ width: `${safePercentage}%` }}
/>
</div>
{resetText && <p className="mt-2 text-xs text-muted-foreground">{resetText}</p>}
</div>
);
};
return ( return (
<div <div
className={cn( className={cn(

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
/** /**
* Hook to detect if a media query matches * Hook to detect if a media query matches
@@ -11,6 +11,9 @@ export function useMediaQuery(query: string): boolean {
return window.matchMedia(query).matches; return window.matchMedia(query).matches;
}); });
// Track if this is the initial mount to avoid redundant setMatches call
const isInitialMount = useRef(true);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@@ -19,8 +22,13 @@ export function useMediaQuery(query: string): boolean {
setMatches(e.matches); setMatches(e.matches);
}; };
// Set initial value // Only sync state when query changes after initial mount
setMatches(mediaQuery.matches); // (initial mount already has correct value from useState initializer)
if (isInitialMount.current) {
isInitialMount.current = false;
} else {
setMatches(mediaQuery.matches);
}
// Listen for changes // Listen for changes
mediaQuery.addEventListener('change', handleChange); mediaQuery.addEventListener('change', handleChange);