|
1 | 1 | "use client"; |
2 | 2 |
|
3 | | -import { useState, useEffect, useMemo, useRef } from "react"; |
| 3 | +import { useState, useEffect, useMemo, useRef, useCallback } from "react"; |
4 | 4 | import { |
5 | 5 | Loader2, |
6 | 6 | FolderTree, |
@@ -106,6 +106,40 @@ import { |
106 | 106 | } from "@/services/queries/use-workflows"; |
107 | 107 | import { useMutation } from "@tanstack/react-query"; |
108 | 108 |
|
| 109 | +// Constants for artifact auto-refresh timing |
| 110 | +// Moved outside component to avoid unnecessary effect re-runs |
| 111 | +// |
| 112 | +// Wait 1 second after last tool completion to batch rapid writes together |
| 113 | +// Prevents excessive API calls during burst writes (e.g., when Claude creates multiple files in quick succession) |
| 114 | +// Testing: 500ms was too aggressive (hit API rate limits), 2000ms felt sluggish to users |
| 115 | +const ARTIFACTS_DEBOUNCE_MS = 1000; |
| 116 | + |
| 117 | +// Wait 2 seconds after session completes before final artifact refresh |
| 118 | +// Backend can take 1-2 seconds to flush final artifacts to storage |
| 119 | +// Ensures users see all artifacts even if final writes occur after status transition |
| 120 | +const COMPLETION_DELAY_MS = 2000; |
| 121 | + |
| 122 | +/** |
| 123 | + * Type guard to check if a message is a completed ToolUseMessages with result. |
| 124 | + * Extracted for testability and proper validation. |
| 125 | + * Uses proper type assertion and validation. |
| 126 | + */ |
| 127 | +function isCompletedToolUseMessage(msg: MessageObject | ToolUseMessages): msg is ToolUseMessages { |
| 128 | + if (msg.type !== "tool_use_messages") { |
| 129 | + return false; |
| 130 | + } |
| 131 | + |
| 132 | + // Cast to ToolUseMessages for proper type checking |
| 133 | + const toolMsg = msg as ToolUseMessages; |
| 134 | + |
| 135 | + return ( |
| 136 | + toolMsg.resultBlock !== undefined && |
| 137 | + toolMsg.resultBlock !== null && |
| 138 | + typeof toolMsg.resultBlock === "object" && |
| 139 | + toolMsg.resultBlock.content !== null |
| 140 | + ); |
| 141 | +} |
| 142 | + |
109 | 143 | export default function ProjectSessionDetailPage({ |
110 | 144 | params, |
111 | 145 | }: { |
@@ -391,16 +425,26 @@ export default function ProjectSessionDetailPage({ |
391 | 425 | basePath: "artifacts", |
392 | 426 | }); |
393 | 427 |
|
394 | | - const { data: artifactsFiles = [], refetch: refetchArtifactsFiles } = |
| 428 | + const { data: artifactsFiles = [], refetch: refetchArtifactsFilesRaw } = |
395 | 429 | useWorkspaceList( |
396 | 430 | projectName, |
397 | 431 | sessionName, |
398 | 432 | artifactsOps.currentSubPath |
399 | 433 | ? `artifacts/${artifactsOps.currentSubPath}` |
400 | 434 | : "artifacts", |
401 | | - { enabled: openAccordionItems.includes("artifacts") }, |
402 | 435 | ); |
403 | 436 |
|
| 437 | + // Stabilize refetchArtifactsFiles with useCallback to prevent infinite re-renders |
| 438 | + // React Query's refetch is already stable, but this ensures proper dependency tracking |
| 439 | + const refetchArtifactsFiles = useCallback(async () => { |
| 440 | + try { |
| 441 | + await refetchArtifactsFilesRaw(); |
| 442 | + } catch (error) { |
| 443 | + console.error('Failed to refresh artifacts:', error); |
| 444 | + // Silent fail - don't interrupt user experience |
| 445 | + } |
| 446 | + }, [refetchArtifactsFilesRaw]); |
| 447 | + |
404 | 448 | // File uploads list (for Context accordion) |
405 | 449 | const { data: fileUploadsList = [], refetch: refetchFileUploadsList } = |
406 | 450 | useWorkspaceList( |
@@ -511,6 +555,77 @@ export default function ProjectSessionDetailPage({ |
511 | 555 | ); |
512 | 556 | }, [messages, session?.spec?.interactive]); |
513 | 557 |
|
| 558 | + // Auto-refresh artifacts when messages complete |
| 559 | + // UX improvement: Automatically refresh the artifacts panel when Claude writes new files, |
| 560 | + // so users can see their changes immediately without manually clicking the refresh button |
| 561 | + const previousToolResultCount = useRef(0); |
| 562 | + const artifactsRefreshTimeoutRef = useRef<NodeJS.Timeout | null>(null); |
| 563 | + const completionTimeoutRef = useRef<NodeJS.Timeout | null>(null); |
| 564 | + const hasRefreshedOnCompletionRef = useRef(false); |
| 565 | + |
| 566 | + // Memoize the completed tool count to avoid redundant filtering |
| 567 | + // Uses extracted type guard for testability and proper validation |
| 568 | + const completedToolCount = useMemo(() => { |
| 569 | + return streamMessages.filter(isCompletedToolUseMessage).length; |
| 570 | + }, [streamMessages]); |
| 571 | + |
| 572 | + useEffect(() => { |
| 573 | + // Initialize on first mount to avoid triggering refresh for existing tools |
| 574 | + if (previousToolResultCount.current === 0 && completedToolCount > 0) { |
| 575 | + previousToolResultCount.current = completedToolCount; |
| 576 | + return; |
| 577 | + } |
| 578 | + |
| 579 | + // If we have new completed tools, refresh artifacts after a short delay |
| 580 | + if (completedToolCount > previousToolResultCount.current && completedToolCount > 0) { |
| 581 | + // Clear any pending refresh timeout |
| 582 | + if (artifactsRefreshTimeoutRef.current) { |
| 583 | + clearTimeout(artifactsRefreshTimeoutRef.current); |
| 584 | + } |
| 585 | + |
| 586 | + // Debounce refresh to avoid excessive calls during rapid tool completions |
| 587 | + artifactsRefreshTimeoutRef.current = setTimeout(() => { |
| 588 | + refetchArtifactsFiles(); |
| 589 | + }, ARTIFACTS_DEBOUNCE_MS); |
| 590 | + |
| 591 | + previousToolResultCount.current = completedToolCount; |
| 592 | + } |
| 593 | + |
| 594 | + // Cleanup timeout on unmount or effect re-run |
| 595 | + return () => { |
| 596 | + if (artifactsRefreshTimeoutRef.current) { |
| 597 | + clearTimeout(artifactsRefreshTimeoutRef.current); |
| 598 | + } |
| 599 | + }; |
| 600 | + }, [completedToolCount, refetchArtifactsFiles]); |
| 601 | + |
| 602 | + // Also refresh artifacts when session completes (catch any final artifacts) |
| 603 | + useEffect(() => { |
| 604 | + const phase = session?.status?.phase; |
| 605 | + if (phase === "Completed" && !hasRefreshedOnCompletionRef.current) { |
| 606 | + // Refresh after a short delay to ensure all final writes are complete |
| 607 | + completionTimeoutRef.current = setTimeout(() => { |
| 608 | + refetchArtifactsFiles(); |
| 609 | + }, COMPLETION_DELAY_MS); |
| 610 | + hasRefreshedOnCompletionRef.current = true; |
| 611 | + } else if (phase !== "Completed") { |
| 612 | + // Clear any pending completion refresh to avoid race conditions |
| 613 | + if (completionTimeoutRef.current) { |
| 614 | + clearTimeout(completionTimeoutRef.current); |
| 615 | + completionTimeoutRef.current = null; |
| 616 | + } |
| 617 | + // Reset flag whenever leaving Completed state (handles Running, Error, Cancelled, etc.) |
| 618 | + hasRefreshedOnCompletionRef.current = false; |
| 619 | + } |
| 620 | + |
| 621 | + // Cleanup timeout on unmount or phase change |
| 622 | + return () => { |
| 623 | + if (completionTimeoutRef.current) { |
| 624 | + clearTimeout(completionTimeoutRef.current); |
| 625 | + } |
| 626 | + }; |
| 627 | + }, [session?.status?.phase, refetchArtifactsFiles]); |
| 628 | + |
514 | 629 | // Session action handlers |
515 | 630 | const handleStop = () => { |
516 | 631 | stopMutation.mutate( |
|
0 commit comments