| ... | @@ -63,11 +63,17 @@ import { | ... | @@ -63,11 +63,17 @@ import { |
| 63 | MAX_THREAD_TERMINAL_COUNT, | 63 | MAX_THREAD_TERMINAL_COUNT, |
| 64 | type ChatMessage, | 64 | type ChatMessage, |
| 65 | type Thread, | 65 | type Thread, |
| | 66 | type TurnDiffFileChange, |
| 66 | type TurnDiffSummary, | 67 | type TurnDiffSummary, |
| 67 | } from "../types"; | 68 | } from "../types"; |
| 68 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; | 69 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; |
| 69 | import { useTheme } from "../hooks/useTheme"; | 70 | import { useTheme } from "../hooks/useTheme"; |
| 70 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | 71 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| | 72 | import { |
| | 73 | buildTurnDiffTree, |
| | 74 | summarizeTurnDiffStats, |
| | 75 | type TurnDiffTreeNode, |
| | 76 | } from "../lib/turnDiffTree"; |
| 71 | import BranchToolbar from "./BranchToolbar"; | 77 | import BranchToolbar from "./BranchToolbar"; |
| 72 | import GitActionsControl from "./GitActionsControl"; | 78 | import GitActionsControl from "./GitActionsControl"; |
| 73 | import { | 79 | import { |
| ... | @@ -3127,6 +3133,126 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri | ... | @@ -3127,6 +3133,126 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri |
| 3127 | ); | 3133 | ); |
| 3128 | }); | 3134 | }); |
| 3129 | | 3135 | |
| | 3136 | function hasNonZeroStat(stat: { additions: number; deletions: number }): boolean { |
| | 3137 | return stat.additions > 0 || stat.deletions > 0; |
| | 3138 | } |
| | 3139 | |
| | 3140 | const DiffStatLabel = memo(function DiffStatLabel(props: { |
| | 3141 | additions: number; |
| | 3142 | deletions: number; |
| | 3143 | showParentheses?: boolean; |
| | 3144 | }) { |
| | 3145 | const { additions, deletions, showParentheses = false } = props; |
| | 3146 | return ( |
| | 3147 | <> |
| | 3148 | {showParentheses && <span className="text-muted-foreground/70">(</span>} |
| | 3149 | <span className="text-success">+{additions}</span> |
| | 3150 | <span className="mx-0.5 text-muted-foreground/70">/</span> |
| | 3151 | <span className="text-destructive">-{deletions}</span> |
| | 3152 | {showParentheses && <span className="text-muted-foreground/70">)</span>} |
| | 3153 | </> |
| | 3154 | ); |
| | 3155 | }); |
| | 3156 | |
| | 3157 | function buildInitiallyExpandedDirectoryState( |
| | 3158 | nodes: ReadonlyArray<TurnDiffTreeNode>, |
| | 3159 | ): Record<string, boolean> { |
| | 3160 | const expandedState: Record<string, boolean> = {}; |
| | 3161 | for (const node of nodes) { |
| | 3162 | if (node.kind === "directory") { |
| | 3163 | expandedState[node.path] = true; |
| | 3164 | } |
| | 3165 | } |
| | 3166 | return expandedState; |
| | 3167 | } |
| | 3168 | |
| | 3169 | const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| | 3170 | turnId: TurnId; |
| | 3171 | files: ReadonlyArray<TurnDiffFileChange>; |
| | 3172 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| | 3173 | }) { |
| | 3174 | const { files, onOpenTurnDiff, turnId } = props; |
| | 3175 | const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); |
| | 3176 | const [expandedDirectories, setExpandedDirectories] = useState<Record<string, boolean>>( |
| | 3177 | () => buildInitiallyExpandedDirectoryState(treeNodes), |
| | 3178 | ); |
| | 3179 | |
| | 3180 | const toggleDirectory = useCallback((pathValue: string) => { |
| | 3181 | setExpandedDirectories((current) => ({ |
| | 3182 | ...current, |
| | 3183 | [pathValue]: !current[pathValue], |
| | 3184 | })); |
| | 3185 | }, []); |
| | 3186 | |
| | 3187 | const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => { |
| | 3188 | const leftPadding = 8 + depth * 14; |
| | 3189 | if (node.kind === "directory") { |
| | 3190 | const isExpanded = expandedDirectories[node.path] ?? depth === 0; |
| | 3191 | return ( |
| | 3192 | <div key={`dir:${node.path}`}> |
| | 3193 | <button |
| | 3194 | type="button" |
| | 3195 | className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80" |
| | 3196 | style={{ paddingLeft: `${leftPadding}px` }} |
| | 3197 | onClick={() => toggleDirectory(node.path)} |
| | 3198 | > |
| | 3199 | <ChevronRightIcon |
| | 3200 | aria-hidden="true" |
| | 3201 | className={cn( |
| | 3202 | "size-3.5 shrink-0 text-muted-foreground/70 transition-transform group-hover:text-foreground/80", |
| | 3203 | isExpanded && "rotate-90", |
| | 3204 | )} |
| | 3205 | /> |
| | 3206 | {isExpanded ? ( |
| | 3207 | <FolderIcon className="size-3.5 shrink-0 text-muted-foreground/75" /> |
| | 3208 | ) : ( |
| | 3209 | <FolderClosedIcon className="size-3.5 shrink-0 text-muted-foreground/75" /> |
| | 3210 | )} |
| | 3211 | <span className="truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90"> |
| | 3212 | {node.name} |
| | 3213 | </span> |
| | 3214 | <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums"> |
| | 3215 | <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} /> |
| | 3216 | </span> |
| | 3217 | </button> |
| | 3218 | {isExpanded && ( |
| | 3219 | <div className="space-y-0.5"> |
| | 3220 | {node.children.map((childNode) => renderTreeNode(childNode, depth + 1))} |
| | 3221 | </div> |
| | 3222 | )} |
| | 3223 | </div> |
| | 3224 | ); |
| | 3225 | } |
| | 3226 | |
| | 3227 | return ( |
| | 3228 | <button |
| | 3229 | key={`file:${node.path}`} |
| | 3230 | type="button" |
| | 3231 | className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80" |
| | 3232 | style={{ paddingLeft: `${leftPadding}px` }} |
| | 3233 | onClick={() => onOpenTurnDiff(turnId, node.path)} |
| | 3234 | > |
| | 3235 | <span aria-hidden="true" className="size-3.5 shrink-0" /> |
| | 3236 | <FileIcon className="size-3.5 shrink-0 text-muted-foreground/70" /> |
| | 3237 | <span className="truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90"> |
| | 3238 | {node.name} |
| | 3239 | </span> |
| | 3240 | {node.stat && ( |
| | 3241 | <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums"> |
| | 3242 | <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} /> |
| | 3243 | </span> |
| | 3244 | )} |
| | 3245 | </button> |
| | 3246 | ); |
| | 3247 | }; |
| | 3248 | |
| | 3249 | return ( |
| | 3250 | <div className="space-y-0.5"> |
| | 3251 | {treeNodes.map((node) => renderTreeNode(node, 0))} |
| | 3252 | </div> |
| | 3253 | ); |
| | 3254 | }); |
| | 3255 | |
| 3130 | interface MessagesTimelineProps { | 3256 | interface MessagesTimelineProps { |
| 3131 | hasMessages: boolean; | 3257 | hasMessages: boolean; |
| 3132 | isWorking: boolean; | 3258 | isWorking: boolean; |
| ... | @@ -3531,33 +3657,20 @@ const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -3531,33 +3657,20 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 3531 | if (!turnSummary) return null; | 3657 | if (!turnSummary) return null; |
| 3532 | const checkpointFiles = turnSummary.files; | 3658 | const checkpointFiles = turnSummary.files; |
| 3533 | if (checkpointFiles.length === 0) return null; | 3659 | if (checkpointFiles.length === 0) return null; |
| 3534 | const summaryStat = checkpointFiles.reduce( | 3660 | const summaryStat = summarizeTurnDiffStats(checkpointFiles); |
| 3535 | (acc, file) => { | | |
| 3536 | if ( | | |
| 3537 | typeof file.additions !== "number" || | | |
| 3538 | typeof file.deletions !== "number" | | |
| 3539 | ) { | | |
| 3540 | return acc; | | |
| 3541 | } | | |
| 3542 | return { | | |
| 3543 | additions: acc.additions + file.additions, | | |
| 3544 | deletions: acc.deletions + file.deletions, | | |
| 3545 | }; | | |
| 3546 | }, | | |
| 3547 | { additions: 0, deletions: 0 }, | | |
| 3548 | ); | | |
| 3549 | const changedFileCountLabel = String(checkpointFiles.length); | 3661 | const changedFileCountLabel = String(checkpointFiles.length); |
| 3550 | return ( | 3662 | return ( |
| 3551 | <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5"> | 3663 | <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5"> |
| 3552 | <div className="mb-1.5 flex items-center justify-between gap-2"> | 3664 | <div className="mb-1.5 flex items-center justify-between gap-2"> |
| 3553 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | 3665 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> |
| 3554 | <span>Changed files ({changedFileCountLabel})</span> | 3666 | <span>Changed files ({changedFileCountLabel})</span> |
| 3555 | {(summaryStat.additions > 0 || summaryStat.deletions > 0) && ( | 3667 | {hasNonZeroStat(summaryStat) && ( |
| 3556 | <> | 3668 | <> |
| 3557 | <span className="mx-1">•</span> | 3669 | <span className="mx-1">•</span> |
| 3558 | <span className="text-success">+{summaryStat.additions}</span> | 3670 | <DiffStatLabel |
| 3559 | <span className="mx-0.5 text-muted-foreground/70">/</span> | 3671 | additions={summaryStat.additions} |
| 3560 | <span className="text-destructive">-{summaryStat.deletions}</span> | 3672 | deletions={summaryStat.deletions} |
| | 3673 | /> |
| 3561 | </> | 3674 | </> |
| 3562 | )} | 3675 | )} |
| 3563 | </p> | 3676 | </p> |
| ... | @@ -3572,40 +3685,12 @@ const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -3572,40 +3685,12 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 3572 | View diff | 3685 | View diff |
| 3573 | </Button> | 3686 | </Button> |
| 3574 | </div> | 3687 | </div> |
| 3575 | <div className="flex flex-wrap gap-1.5"> | 3688 | <ChangedFilesTree |
| 3576 | {checkpointFiles.map((file) => ( | 3689 | key={`changed-files-tree:${turnSummary.turnId}`} |
| 3577 | <button | 3690 | turnId={turnSummary.turnId} |
| 3578 | key={`${turnSummary.turnId}:${file.path}`} | 3691 | files={checkpointFiles} |
| 3579 | type="button" | 3692 | onOpenTurnDiff={onOpenTurnDiff} |
| 3580 | className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90" | 3693 | /> |
| 3581 | onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)} | | |
| 3582 | > | | |
| 3583 | {(() => { | | |
| 3584 | const stat = | | |
| 3585 | typeof file.additions === "number" && | | |
| 3586 | typeof file.deletions === "number" | | |
| 3587 | ? { | | |
| 3588 | additions: file.additions, | | |
| 3589 | deletions: file.deletions, | | |
| 3590 | } | | |
| 3591 | : null; | | |
| 3592 | if (!stat) { | | |
| 3593 | return file.path; | | |
| 3594 | } | | |
| 3595 | return ( | | |
| 3596 | <> | | |
| 3597 | <span>{file.path}</span> | | |
| 3598 | <span className="ml-1 text-muted-foreground/70">(</span> | | |
| 3599 | <span className="text-success">+{stat.additions}</span> | | |
| 3600 | <span className="mx-0.5 text-muted-foreground/70">/</span> | | |
| 3601 | <span className="text-destructive">-{stat.deletions}</span> | | |
| 3602 | <span className="text-muted-foreground/70">)</span> | | |
| 3603 | </> | | |
| 3604 | ); | | |
| 3605 | })()} | | |
| 3606 | </button> | | |
| 3607 | ))} | | |
| 3608 | </div> | | |
| 3609 | </div> | 3694 | </div> |
| 3610 | ); | 3695 | ); |
| 3611 | })()} | 3696 | })()} |