auto-claude: 181-add-expand-button-for-long-task-descriptions (#1653)

* auto-claude: subtask-1-1 - Add expandable description container with toggle button

- Add isExpanded and hasOverflow state for expand/collapse functionality
- Add useLayoutEffect to detect content overflow (scrollHeight > clientHeight)
- Apply max-h-[200px] with overflow-hidden when collapsed
- Add gradient overlay at bottom when content is truncated
- Add centered ghost button with ChevronDown/ChevronUp icons
- Add i18n translations for showMore/showLess in en and fr

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

* fix: remove unrelated changes from branch (qa-requested)

Reset files that were not related to the expand button feature back to
their develop branch state:
- .gitignore
- apps/backend/agents/ (base.py, coder.py, planner.py, session.py)
- apps/backend/core/ (client.py, simple_client.py)
- apps/frontend/src/renderer/App.tsx
- apps/frontend/src/renderer/components/AuthStatusIndicator.tsx
- apps/frontend/src/renderer/components/KanbanBoard.tsx
- apps/frontend/src/renderer/stores/task-store.ts
- apps/frontend/src/shared/i18n/locales/*/common.json
- tests/test_auth.py
- tests/test_issue_884_plan_schema.py

The expand button feature implementation remains intact.

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

* fix: resolve CI failures in Python tests and lint

- Fix test_integration_phase4.py: Register module in sys.modules before
  exec_module to allow dataclass decorator to find module by name
- Fix ruff format issues in parallel_orchestrator_reviewer.py: Break
  long f-string lines for logger.error and RuntimeError calls
- Fix ruff format issues in pydantic_models.py: Combine Field description
  on single line

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

* fix: resolve PR review findings - reset expand state, fix test mocks, remove dead code

- Reset isExpanded when switching tasks to prevent stale expanded state leaking between tasks
- Fix all remaining get_token_from_keychain mock signatures to accept _config_dir parameter
- Remove disabled old orchestrator code block in parallel_orchestrator_reviewer.py

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

* fix: resolve PR review critical and medium issues

- Restore missing constants in base.py that coder.py imports
  (MAX_CONCURRENCY_RETRIES, INITIAL_RETRY_DELAY_SECONDS, MAX_RETRY_DELAY_SECONDS)
- Fix test_issue_884_plan_schema.py mock return types to match
  run_agent_session 3-tuple signature (str, str, dict)
- Add accessibility attributes to expand/collapse button in TaskMetadata.tsx
  (aria-expanded, aria-controls, aria-hidden on icons, id on content)

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

* fix: restore loadClaudeProfiles() and add trailing newline to .gitignore

- Restore loadClaudeProfiles() call in App.tsx initial load useEffect
  to fix onboarding detection for OAuth-only users
- Add trailing newline to .gitignore per POSIX convention

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Test User <test@example.com>
This commit is contained in:
Andy
2026-02-04 14:06:40 +01:00
committed by GitHub
parent f5a7e26d99
commit d9cd300fee
13 changed files with 149 additions and 291 deletions
-1
View File
@@ -174,4 +174,3 @@ OPUS_ANALYSIS_AND_IDEAS.md
/shared_docs /shared_docs
logs/security/ logs/security/
Agents.md Agents.md
packages/
+4 -6
View File
@@ -14,9 +14,7 @@ logger = logging.getLogger(__name__)
AUTO_CONTINUE_DELAY_SECONDS = 3 AUTO_CONTINUE_DELAY_SECONDS = 3
HUMAN_INTERVENTION_FILE = "PAUSE" HUMAN_INTERVENTION_FILE = "PAUSE"
# Retry configuration for 400 tool concurrency errors # Concurrency retry constants
MAX_CONCURRENCY_RETRIES = 5 # Maximum number of retries for tool concurrency errors MAX_CONCURRENCY_RETRIES = 5
INITIAL_RETRY_DELAY_SECONDS = ( INITIAL_RETRY_DELAY_SECONDS = 2
2 # Initial retry delay (doubles each retry: 2s, 4s, 8s, 16s, 32s) MAX_RETRY_DELAY_SECONDS = 32
)
MAX_RETRY_DELAY_SECONDS = 32 # Cap retry delay at 32 seconds
@@ -1116,85 +1116,6 @@ The SDK will run invoked agents in parallel automatically.
f"{len(findings)} findings from {len(agents_invoked)} agents" f"{len(findings)} findings from {len(agents_invoked)} agents"
) )
# Skip the old orchestrator session code - findings come from parallel specialists
# The code below (structured output parsing, retries, etc.) is no longer needed
# as _run_parallel_specialists handles everything
# NOTE: The following block is kept but skipped via this marker
if False: # DISABLED: Old orchestrator + Task tool approach
# Old code for reference - to be removed after testing
prompt = self._build_orchestrator_prompt(context)
agent_defs = self._define_specialist_agents(project_root)
client = self._create_sdk_client(project_root, model, thinking_budget)
MAX_RETRIES = 3
RETRY_DELAY = 2.0
result_text = ""
structured_output = None
msg_count = 0
last_error = None
for attempt in range(MAX_RETRIES):
if attempt > 0:
logger.info(
f"[ParallelOrchestrator] Retry attempt {attempt}/{MAX_RETRIES - 1} "
f"after tool concurrency error"
)
safe_print(
f"[ParallelOrchestrator] Retry {attempt}/{MAX_RETRIES - 1} "
f"(tool concurrency error detected)"
)
await asyncio.sleep(RETRY_DELAY)
client = self._create_sdk_client(
project_root, model, thinking_budget
)
try:
async with client:
await client.query(prompt)
safe_print(
f"[ParallelOrchestrator] Running orchestrator ({model})...",
flush=True,
)
stream_result = await process_sdk_stream(
client=client,
context_name="ParallelOrchestrator",
model=model,
system_prompt=prompt,
agent_definitions=agent_defs,
)
error = stream_result.get("error")
if (
error == "tool_use_concurrency_error"
and attempt < MAX_RETRIES - 1
):
last_error = error
continue
if error:
raise RuntimeError(
f"SDK stream processing failed: {error}"
)
result_text = stream_result["result_text"]
structured_output = stream_result["structured_output"]
agents_invoked = stream_result["agents_invoked"]
break
except Exception as e:
if attempt < MAX_RETRIES - 1:
last_error = str(e)
continue
raise
else:
raise RuntimeError(
f"Orchestrator failed after {MAX_RETRIES} attempts"
)
# END DISABLED BLOCK
self._report_progress( self._report_progress(
"finalizing", "finalizing",
50, 50,
@@ -23,7 +23,6 @@ import {
} from './ui/tooltip'; } from './ui/tooltip';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSettingsStore } from '../stores/settings-store'; import { useSettingsStore } from '../stores/settings-store';
import { useClaudeProfileStore } from '../stores/claude-profile-store';
import { detectProvider, getProviderLabel, getProviderBadgeColor, type ApiProvider } from '../../shared/utils/provider-detection'; import { detectProvider, getProviderLabel, getProviderBadgeColor, type ApiProvider } from '../../shared/utils/provider-detection';
import { formatTimeRemaining, localizeUsageWindowLabel, hasHardcodedText } from '../../shared/utils/format-time'; import { formatTimeRemaining, localizeUsageWindowLabel, hasHardcodedText } from '../../shared/utils/format-time';
import type { ClaudeUsageSnapshot } from '../../shared/types/agent'; import type { ClaudeUsageSnapshot } from '../../shared/types/agent';
@@ -50,13 +49,8 @@ const OAUTH_FALLBACK = {
} as const; } as const;
export function AuthStatusIndicator() { export function AuthStatusIndicator() {
// Subscribe to profile state from settings store (API profiles) // Subscribe to profile state from settings store
const { profiles, activeProfileId } = useSettingsStore(); const { profiles, activeProfileId } = useSettingsStore();
// Subscribe to Claude OAuth profile state
const claudeProfiles = useClaudeProfileStore((state) => state.profiles);
const activeClaudeProfileId = useClaudeProfileStore((state) => state.activeProfileId);
const { t } = useTranslation(['common']); const { t } = useTranslation(['common']);
// Track usage data for warning badge // Track usage data for warning badge
@@ -108,7 +102,6 @@ export function AuthStatusIndicator() {
// Compute auth status and provider detection using useMemo to avoid unnecessary re-renders // Compute auth status and provider detection using useMemo to avoid unnecessary re-renders
const authStatus = useMemo(() => { const authStatus = useMemo(() => {
// First check if user is using API profile auth (has active API profile)
if (activeProfileId) { if (activeProfileId) {
const activeProfile = profiles.find(p => p.id === activeProfileId); const activeProfile = profiles.find(p => p.id === activeProfileId);
if (activeProfile) { if (activeProfile) {
@@ -126,36 +119,12 @@ export function AuthStatusIndicator() {
badgeColor: getProviderBadgeColor(provider) badgeColor: getProviderBadgeColor(provider)
}; };
} }
// Profile ID set but profile not found - fallback to OAuth
return OAUTH_FALLBACK;
} }
// No active profile - using OAuth
// No active API profile - check Claude OAuth profiles directly
if (activeClaudeProfileId && claudeProfiles.length > 0) {
const activeClaudeProfile = claudeProfiles.find(p => p.id === activeClaudeProfileId);
if (activeClaudeProfile) {
return {
type: 'oauth' as const,
name: activeClaudeProfile.email || activeClaudeProfile.name,
provider: 'anthropic' as const,
providerLabel: 'Anthropic',
badgeColor: 'bg-orange-500/10 text-orange-500 border-orange-500/20 hover:bg-orange-500/15'
};
}
}
// Fallback to usage data if Claude profiles aren't loaded yet
if (usage && (usage.profileName || usage.profileEmail)) {
return {
type: 'oauth' as const,
name: usage.profileEmail || usage.profileName,
provider: 'anthropic' as const,
providerLabel: 'Anthropic',
badgeColor: 'bg-orange-500/10 text-orange-500 border-orange-500/20 hover:bg-orange-500/15'
};
}
// No auth info available - fallback to generic OAuth
return OAUTH_FALLBACK; return OAUTH_FALLBACK;
}, [activeProfileId, profiles, activeClaudeProfileId, claudeProfiles, usage]); }, [activeProfileId, profiles]);
// Helper function to truncate ID for display // Helper function to truncate ID for display
const truncateId = (id: string): string => { const truncateId = (id: string): string => {
@@ -305,22 +274,6 @@ export function AuthStatusIndicator() {
</div> </div>
</> </>
)} )}
{/* Account details for OAuth profiles */}
{isOAuth && authStatus.name && authStatus.name !== 'OAuth' && (
<>
<div className="pt-2 border-t space-y-2">
{/* Account name/email with icon */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Lock className="h-3 w-3" />
<span className="text-[10px]">{t('common:usage.account')}</span>
</div>
<span className="font-medium text-[10px]">{authStatus.name}</span>
</div>
</div>
</>
)}
</div> </div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -28,7 +28,6 @@ import { TaskCard } from './TaskCard';
import { SortableTaskCard } from './SortableTaskCard'; import { SortableTaskCard } from './SortableTaskCard';
import { QueueSettingsModal } from './QueueSettingsModal'; import { QueueSettingsModal } from './QueueSettingsModal';
import { TASK_STATUS_COLUMNS, TASK_STATUS_LABELS } from '../../shared/constants'; import { TASK_STATUS_COLUMNS, TASK_STATUS_LABELS } from '../../shared/constants';
import { debugLog } from '../../shared/utils/debug-logger';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { persistTaskStatus, forceCompleteTask, archiveTasks, deleteTasks, useTaskStore } from '../stores/task-store'; import { persistTaskStatus, forceCompleteTask, archiveTasks, deleteTasks, useTaskStore } from '../stores/task-store';
import { updateProjectSettings, useProjectStore } from '../stores/project-store'; import { updateProjectSettings, useProjectStore } from '../stores/project-store';
@@ -1068,74 +1067,29 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
isProcessingQueueRef.current = true; isProcessingQueueRef.current = true;
try { try {
// Track tasks we've already processed in this call to prevent duplicates // Track tasks we've already attempted to promote (to avoid infinite retries)
// This is critical because store updates happen synchronously but we need to ensure const attemptedTaskIds = new Set<string>();
// we never process the same task twice, even if there are timing issues
const processedTaskIds = new Set<string>();
let consecutiveFailures = 0; let consecutiveFailures = 0;
const MAX_CONSECUTIVE_FAILURES = 10; // Safety limit to prevent infinite loop const MAX_CONSECUTIVE_FAILURES = 10; // Safety limit to prevent infinite loop
// Track promotions in this call to enforce max parallel tasks limit
let promotedInThisCall = 0;
// Log initial state
const initialTasks = useTaskStore.getState().tasks;
const initialInProgress = initialTasks.filter((t) => t.status === 'in_progress' && !t.metadata?.archivedAt);
const initialQueued = initialTasks.filter((t) => t.status === 'queue' && !t.metadata?.archivedAt);
debugLog(`[Queue] === PROCESS QUEUE START ===`, {
maxParallelTasks,
initialInProgressCount: initialInProgress.length,
initialInProgressIds: initialInProgress.map(t => t.id),
initialQueuedCount: initialQueued.length,
initialQueuedIds: initialQueued.map(t => t.id),
projectId
});
// Loop until capacity is full or queue is empty // Loop until capacity is full or queue is empty
let iteration = 0;
while (true) { while (true) {
iteration++; // Get CURRENT state from store to ensure accuracy
// Calculate total in-progress count: tasks that were already in progress + tasks promoted in this call const currentTasks = useTaskStore.getState().tasks;
const totalInProgressCount = initialInProgress.length + promotedInThisCall; const inProgressCount = currentTasks.filter((t) =>
t.status === 'in_progress' && !t.metadata?.archivedAt
debugLog(`[Queue] --- Iteration ${iteration} ---`, { ).length;
initialInProgressCount: initialInProgress.length, const queuedTasks = currentTasks.filter((t) =>
promotedInThisCall, t.status === 'queue' && !t.metadata?.archivedAt && !attemptedTaskIds.has(t.id)
totalInProgressCount,
capacityCheck: totalInProgressCount >= maxParallelTasks,
processedCount: processedTaskIds.size
});
// Stop if no capacity (initial in-progress + promoted in this call)
if (totalInProgressCount >= maxParallelTasks) {
debugLog(`[Queue] Capacity reached (${totalInProgressCount}/${maxParallelTasks}), stopping queue processing`);
break;
}
// Get CURRENT state from store to find queued tasks
const latestTasks = useTaskStore.getState().tasks;
const latestInProgress = latestTasks.filter((t) => t.status === 'in_progress' && !t.metadata?.archivedAt);
const queuedTasks = latestTasks.filter((t) =>
t.status === 'queue' && !t.metadata?.archivedAt && !processedTaskIds.has(t.id)
); );
debugLog(`[Queue] Current store state:`, { // Stop if no capacity, no queued tasks, or too many consecutive failures
totalTasks: latestTasks.length, if (inProgressCount >= maxParallelTasks || queuedTasks.length === 0) {
inProgressCount: latestInProgress.length,
inProgressIds: latestInProgress.map(t => t.id),
queuedCount: queuedTasks.length,
queuedIds: queuedTasks.map(t => t.id),
processedIds: Array.from(processedTaskIds)
});
// Stop if no queued tasks or too many consecutive failures
if (queuedTasks.length === 0) {
debugLog('[Queue] No more queued tasks to process');
break; break;
} }
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
debugLog(`[Queue] Stopping queue processing after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`); console.warn(`[Queue] Stopping queue processing after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`);
break; break;
} }
@@ -1146,62 +1100,28 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
return dateA - dateB; // Ascending order (oldest first) return dateA - dateB; // Ascending order (oldest first)
})[0]; })[0];
debugLog(`[Queue] Selected task for promotion:`, { console.log(`[Queue] Auto-promoting task ${nextTask.id} from Queue to In Progress (${inProgressCount + 1}/${maxParallelTasks})`);
id: nextTask.id,
currentStatus: nextTask.status,
title: nextTask.title?.substring(0, 50)
});
// Mark task as processed BEFORE attempting promotion to prevent duplicates
processedTaskIds.add(nextTask.id);
debugLog(`[Queue] Promoting task ${nextTask.id} (${promotedInThisCall + 1}/${maxParallelTasks})`);
const result = await persistTaskStatus(nextTask.id, 'in_progress'); const result = await persistTaskStatus(nextTask.id, 'in_progress');
// Check store state after promotion
const afterPromoteTasks = useTaskStore.getState().tasks;
const afterPromoteInProgress = afterPromoteTasks.filter((t) => t.status === 'in_progress' && !t.metadata?.archivedAt);
const afterPromoteQueued = afterPromoteTasks.filter((t) => t.status === 'queue' && !t.metadata?.archivedAt);
debugLog(`[Queue] After promotion attempt:`, {
resultSuccess: result.success,
promotedInThisCall,
inProgressCount: afterPromoteInProgress.length,
inProgressIds: afterPromoteInProgress.map(t => t.id),
queuedCount: afterPromoteQueued.length,
queuedIds: afterPromoteQueued.map(t => t.id)
});
if (result.success) { if (result.success) {
// Increment our local promotion counter
promotedInThisCall++;
// Reset consecutive failures on success // Reset consecutive failures on success
consecutiveFailures = 0; consecutiveFailures = 0;
} else { } else {
// If promotion failed, log error and continue to next task // If promotion failed, log error, mark as attempted, and skip to next task
console.error(`[Queue] Failed to promote task ${nextTask.id} to In Progress:`, result.error); console.error(`[Queue] Failed to promote task ${nextTask.id} to In Progress:`, result.error);
attemptedTaskIds.add(nextTask.id);
consecutiveFailures++; consecutiveFailures++;
} }
} }
// Log summary // Log if we had failed tasks
debugLog(`[Queue] === PROCESS QUEUE COMPLETE ===`, { if (attemptedTaskIds.size > 0) {
totalIterations: iteration, console.warn(`[Queue] Skipped ${attemptedTaskIds.size} task(s) that failed to promote`);
tasksProcessed: processedTaskIds.size,
tasksPromoted: promotedInThisCall,
processedIds: Array.from(processedTaskIds)
});
// Trigger UI refresh if tasks were promoted to ensure UI reflects all changes
// This handles the case where store updates are batched/delayed via IPC events
if (promotedInThisCall > 0 && onRefresh) {
debugLog('[Queue] Triggering UI refresh after queue promotion');
onRefresh();
} }
} finally { } finally {
isProcessingQueueRef.current = false; isProcessingQueueRef.current = false;
} }
}, [maxParallelTasks, projectId, onRefresh]); }, [maxParallelTasks]);
// Register task status change listener for queue auto-promotion // Register task status change listener for queue auto-promotion
// This ensures processQueue() is called whenever a task leaves in_progress // This ensures processQueue() is called whenever a task leaves in_progress
@@ -1210,7 +1130,7 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
(taskId, oldStatus, newStatus) => { (taskId, oldStatus, newStatus) => {
// When a task leaves in_progress (e.g., goes to human_review), process the queue // When a task leaves in_progress (e.g., goes to human_review), process the queue
if (oldStatus === 'in_progress' && newStatus !== 'in_progress') { if (oldStatus === 'in_progress' && newStatus !== 'in_progress') {
debugLog(`[Queue] Task ${taskId} left in_progress, processing queue to fill slot`); console.log(`[Queue] Task ${taskId} left in_progress, processing queue to fill slot`);
processQueue(); processQueue();
} }
} }
@@ -1,3 +1,4 @@
import { useState, useRef, useLayoutEffect, useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Target, Target,
@@ -13,11 +14,14 @@ import {
GitPullRequest, GitPullRequest,
ListChecks, ListChecks,
Clock, Clock,
ExternalLink ExternalLink,
ChevronDown,
ChevronUp
} from 'lucide-react'; } from 'lucide-react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { Badge } from '../ui/badge'; import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { cn, formatRelativeTime } from '../../lib/utils'; import { cn, formatRelativeTime } from '../../lib/utils';
import { import {
@@ -51,8 +55,15 @@ interface TaskMetadataProps {
task: Task; task: Task;
} }
// Height threshold for collapsing long descriptions (~8 lines)
const COLLAPSED_HEIGHT = 200;
export function TaskMetadata({ task }: TaskMetadataProps) { export function TaskMetadata({ task }: TaskMetadataProps) {
const { t } = useTranslation(['tasks', 'errors']); const { t } = useTranslation(['tasks', 'errors']);
const [isExpanded, setIsExpanded] = useState(false);
const [hasOverflow, setHasOverflow] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const contentId = useId();
// Handle JSON error description with i18n // Handle JSON error description with i18n
const displayDescription = (() => { const displayDescription = (() => {
@@ -64,6 +75,19 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
return task.description; return task.description;
})(); })();
// Detect if content overflows the collapsed height
// Re-check when description changes (content height depends on rendered description)
// Reset expand state when switching tasks to avoid stale expanded state
// biome-ignore lint/correctness/useExhaustiveDependencies: task.description triggers re-render which changes content height
useLayoutEffect(() => {
setIsExpanded(false);
const element = contentRef.current;
if (element) {
const hasContentOverflow = element.scrollHeight > COLLAPSED_HEIGHT;
setHasOverflow(hasContentOverflow);
}
}, [task.id, task.description]);
const hasClassification = task.metadata && ( const hasClassification = task.metadata && (
task.metadata.category || task.metadata.category ||
task.metadata.priority || task.metadata.priority ||
@@ -155,14 +179,53 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
{/* Description - Primary Content */} {/* Description - Primary Content */}
{displayDescription && ( {displayDescription && (
<div className="bg-muted/30 rounded-lg px-4 py-3 border border-border/50 overflow-hidden max-w-full"> <div className="bg-muted/30 rounded-lg px-4 py-3 border border-border/50 overflow-hidden max-w-full">
<div {/* Content container with conditional max-height */}
className="prose prose-sm dark:prose-invert max-w-none overflow-hidden prose-p:text-foreground/90 prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-li:text-foreground/90 prose-ul:my-2 prose-li:my-0.5 prose-a:break-all prose-pre:overflow-x-auto prose-img:max-w-full [&_img]:!max-w-full [&_img]:h-auto [&_code]:break-all [&_code]:whitespace-pre-wrap [&_*]:max-w-full" <div className="relative">
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }} <div
> ref={contentRef}
<ReactMarkdown remarkPlugins={[remarkGfm]}> id={contentId}
{displayDescription} className={cn(
</ReactMarkdown> 'prose prose-sm dark:prose-invert max-w-none overflow-hidden prose-p:text-foreground/90 prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-li:text-foreground/90 prose-ul:my-2 prose-li:my-0.5 prose-a:break-all prose-pre:overflow-x-auto prose-img:max-w-full [&_img]:!max-w-full [&_img]:h-auto [&_code]:break-all [&_code]:whitespace-pre-wrap [&_*]:max-w-full',
!isExpanded && hasOverflow && 'max-h-[200px]'
)}
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{displayDescription}
</ReactMarkdown>
</div>
{/* Gradient overlay when collapsed and has overflow */}
{!isExpanded && hasOverflow && (
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-muted/80 to-transparent pointer-events-none" />
)}
</div> </div>
{/* Expand/Collapse button */}
{hasOverflow && (
<div className="flex justify-center mt-2">
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
className="text-muted-foreground hover:text-foreground"
aria-expanded={isExpanded}
aria-controls={contentId}
>
{isExpanded ? (
<>
<ChevronUp className="h-4 w-4 mr-1" aria-hidden="true" />
{t('tasks:metadata.showLess')}
</>
) : (
<>
<ChevronDown className="h-4 w-4 mr-1" aria-hidden="true" />
{t('tasks:metadata.showMore')}
</>
)}
</Button>
</div>
)}
</div> </div>
)} )}
+29 -30
View File
@@ -241,7 +241,7 @@ export const useTaskStore = create<TaskState>((set, get) => ({
const state = get(); const state = get();
const index = findTaskIndex(state.tasks, taskId); const index = findTaskIndex(state.tasks, taskId);
if (index === -1) { if (index === -1) {
console.warn('[updateTaskStatus] Task not found:', taskId); debugLog('[updateTaskStatus] Task not found:', taskId);
return; return;
} }
const oldTask = state.tasks[index]; const oldTask = state.tasks[index];
@@ -262,29 +262,38 @@ export const useTaskStore = create<TaskState>((set, get) => ({
// Perform the state update // Perform the state update
set((state) => { set((state) => {
const updatedTasks = updateTaskAtIndex(state.tasks, index, (t) => { return {
// Determine execution progress based on status transition tasks: updateTaskAtIndex(state.tasks, index, (t) => {
let executionProgress = t.executionProgress; // Determine execution progress based on status transition
let executionProgress = t.executionProgress;
if (status === 'backlog') { // Track status transition for debugging flip-flop issues
// When status goes to backlog, reset execution progress to idle const previousStatus = t.status;
// This ensures the planning/coding animation stops when task is stopped const statusChanged = previousStatus !== status;
executionProgress = { phase: 'idle' as ExecutionPhase, phaseProgress: 0, overallProgress: 0 };
} else if (status === 'in_progress' && !t.executionProgress?.phase) {
// When starting a task and no phase is set yet, default to planning
// This prevents the "no active phase" UI state during startup race condition
executionProgress = { phase: 'planning' as ExecutionPhase, phaseProgress: 0, overallProgress: 0 };
}
return { ...t, status, reviewReason, executionProgress, updatedAt: new Date() }; if (status === 'backlog') {
}); // When status goes to backlog, reset execution progress to idle
// This ensures the planning/coding animation stops when task is stopped
executionProgress = { phase: 'idle' as ExecutionPhase, phaseProgress: 0, overallProgress: 0 };
} else if (status === 'in_progress' && !t.executionProgress?.phase) {
// When starting a task and no phase is set yet, default to planning
// This prevents the "no active phase" UI state during startup race condition
executionProgress = { phase: 'planning' as ExecutionPhase, phaseProgress: 0, overallProgress: 0 };
}
debugLog('[updateTaskStatus] AFTER set():', { // Log status transitions to help diagnose flip-flop issues
taskId, debugLog('[updateTaskStatus] Status transition:', {
allInProgress: updatedTasks.filter((t: Task) => t.status === 'in_progress' && !t.metadata?.archivedAt).map(t => t.id) taskId,
}); previousStatus,
newStatus: status,
statusChanged,
currentPhase: t.executionProgress?.phase,
newPhase: executionProgress?.phase
});
return { tasks: updatedTasks }; return { ...t, status, reviewReason, executionProgress, updatedAt: new Date() };
})
};
}); });
// Notify listeners after state update (schedule after current tick) // Notify listeners after state update (schedule after current tick)
@@ -723,17 +732,7 @@ export async function persistTaskStatus(
} }
// Only update local state after backend confirms success // Only update local state after backend confirms success
debugLog(`[persistTaskStatus] BEFORE store.updateTaskStatus:`, {
taskId,
newStatus: status,
currentStoreStatus: store.tasks.find(t => t.id === taskId)?.status
});
store.updateTaskStatus(taskId, status); store.updateTaskStatus(taskId, status);
debugLog(`[persistTaskStatus] AFTER store.updateTaskStatus:`, {
taskId,
updatedStoreStatus: store.tasks.find(t => t.id === taskId)?.status,
allInProgress: store.tasks.filter(t => t.status === 'in_progress' && !t.metadata?.archivedAt).map(t => t.id)
});
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error persisting task status:', error); console.error('Error persisting task status:', error);
@@ -478,8 +478,7 @@
"clickToOpenSettings": "Click to open Settings →", "clickToOpenSettings": "Click to open Settings →",
"sessionShort": "5-hour session usage", "sessionShort": "5-hour session usage",
"weeklyShort": "7-day weekly usage", "weeklyShort": "7-day weekly usage",
"swap": "Swap", "swap": "Swap"
"account": "Account"
}, },
"oauth": { "oauth": {
"enterCode": "Manual Code Entry (Fallback)", "enterCode": "Manual Code Entry (Fallback)",
@@ -174,7 +174,9 @@
}, },
"metadata": { "metadata": {
"severity": "severity", "severity": "severity",
"pullRequest": "Pull Request" "pullRequest": "Pull Request",
"showMore": "Show more",
"showLess": "Show less"
}, },
"images": { "images": {
"removeImageAriaLabel": "Remove image {{filename}}", "removeImageAriaLabel": "Remove image {{filename}}",
@@ -478,8 +478,7 @@
"clickToOpenSettings": "Cliquez pour ouvrir les Paramètres →", "clickToOpenSettings": "Cliquez pour ouvrir les Paramètres →",
"sessionShort": "Utilisation session 5 heures", "sessionShort": "Utilisation session 5 heures",
"weeklyShort": "Utilisation hebdomadaire 7 jours", "weeklyShort": "Utilisation hebdomadaire 7 jours",
"swap": "Changer", "swap": "Changer"
"account": "Compte"
}, },
"oauth": { "oauth": {
"enterCode": "Saisie manuelle du code (secours)", "enterCode": "Saisie manuelle du code (secours)",
@@ -173,7 +173,9 @@
}, },
"metadata": { "metadata": {
"severity": "sévérité", "severity": "sévérité",
"pullRequest": "Pull Request" "pullRequest": "Pull Request",
"showMore": "Afficher plus",
"showLess": "Afficher moins"
}, },
"images": { "images": {
"removeImageAriaLabel": "Supprimer l'image {{filename}}", "removeImageAriaLabel": "Supprimer l'image {{filename}}",
+9 -9
View File
@@ -465,7 +465,7 @@ class TestEnsureClaudeCodeOAuthToken:
"""Doesn't set env var when no auth token is available.""" """Doesn't set env var when no auth token is available."""
monkeypatch.setattr(platform, "system", lambda: "Linux") monkeypatch.setattr(platform, "system", lambda: "Linux")
# Ensure keychain returns None # Ensure keychain returns None
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda config_dir=None: None) monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: None)
ensure_claude_code_oauth_token() ensure_claude_code_oauth_token()
@@ -649,7 +649,7 @@ class TestTokenDecryption:
from unittest.mock import patch from unittest.mock import patch
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "enc:testtoken123456789") monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "enc:testtoken123456789")
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda config_dir=None: None) monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: None)
with patch("core.auth.decrypt_token") as mock_decrypt: with patch("core.auth.decrypt_token") as mock_decrypt:
# Simulate decryption failure # Simulate decryption failure
@@ -672,7 +672,7 @@ class TestTokenDecryption:
decrypted_token = "sk-ant-oat01-decrypted-token" decrypted_token = "sk-ant-oat01-decrypted-token"
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", encrypted_token) monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", encrypted_token)
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda config_dir=None: None) monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: None)
with patch("core.auth.decrypt_token") as mock_decrypt: with patch("core.auth.decrypt_token") as mock_decrypt:
mock_decrypt.return_value = decrypted_token mock_decrypt.return_value = decrypted_token
@@ -690,7 +690,7 @@ class TestTokenDecryption:
"""Verify plaintext tokens continue to work unchanged.""" """Verify plaintext tokens continue to work unchanged."""
token = "sk-ant-oat01-test" token = "sk-ant-oat01-test"
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", token) monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", token)
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda config_dir=None: None) monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: None)
from core.auth import get_auth_token from core.auth import get_auth_token
@@ -993,7 +993,7 @@ class TestTokenDecryptionKeychain:
encrypted_token = "enc:keychaintoken1234" encrypted_token = "enc:keychaintoken1234"
monkeypatch.setattr( monkeypatch.setattr(
"core.auth.get_token_from_keychain", lambda config_dir=None: encrypted_token "core.auth.get_token_from_keychain", lambda _config_dir=None: encrypted_token
) )
with patch("core.auth.decrypt_token") as mock_decrypt: with patch("core.auth.decrypt_token") as mock_decrypt:
@@ -1015,7 +1015,7 @@ class TestTokenDecryptionKeychain:
decrypted_token = "sk-ant-oat01-from-keychain" decrypted_token = "sk-ant-oat01-from-keychain"
monkeypatch.setattr( monkeypatch.setattr(
"core.auth.get_token_from_keychain", lambda config_dir=None: encrypted_token "core.auth.get_token_from_keychain", lambda _config_dir=None: encrypted_token
) )
with patch("core.auth.decrypt_token") as mock_decrypt: with patch("core.auth.decrypt_token") as mock_decrypt:
@@ -1034,7 +1034,7 @@ class TestTokenDecryptionKeychain:
plaintext_token = "sk-ant-oat01-keychain-plaintext" plaintext_token = "sk-ant-oat01-keychain-plaintext"
monkeypatch.setattr( monkeypatch.setattr(
"core.auth.get_token_from_keychain", lambda config_dir=None: plaintext_token "core.auth.get_token_from_keychain", lambda _config_dir=None: plaintext_token
) )
with patch("core.auth.decrypt_token") as mock_decrypt: with patch("core.auth.decrypt_token") as mock_decrypt:
@@ -1052,7 +1052,7 @@ class TestTokenDecryptionKeychain:
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", env_token) monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", env_token)
monkeypatch.setattr( monkeypatch.setattr(
"core.auth.get_token_from_keychain", lambda config_dir=None: keychain_token "core.auth.get_token_from_keychain", lambda _config_dir=None: keychain_token
) )
from core.auth import get_auth_token from core.auth import get_auth_token
@@ -1070,7 +1070,7 @@ class TestTokenDecryptionKeychain:
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", encrypted_env) monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", encrypted_env)
monkeypatch.setattr( monkeypatch.setattr(
"core.auth.get_token_from_keychain", lambda config_dir=None: keychain_token "core.auth.get_token_from_keychain", lambda _config_dir=None: keychain_token
) )
with patch("core.auth.decrypt_token") as mock_decrypt: with patch("core.auth.decrypt_token") as mock_decrypt:
+3
View File
@@ -106,6 +106,9 @@ _modules_to_mock = [
_original_modules = {name: sys.modules.get(name) for name in _modules_to_mock} _original_modules = {name: sys.modules.get(name) for name in _modules_to_mock}
for name in _modules_to_mock: for name in _modules_to_mock:
sys.modules[name] = MagicMock() sys.modules[name] = MagicMock()
# IMPORTANT: Register the module in sys.modules BEFORE exec_module
# This is required for dataclass decorators to find the module by name
sys.modules["parallel_orchestrator_reviewer"] = orchestrator_module
orchestrator_spec.loader.exec_module(orchestrator_module) orchestrator_spec.loader.exec_module(orchestrator_module)
# Restore all mocked modules to avoid polluting other tests # Restore all mocked modules to avoid polluting other tests
for name in _modules_to_mock: for name in _modules_to_mock: