1import { ThreadDetailsControl } from "./chat/ThreadDetailsControl";
2import { useAtomValue } from "@effect/atom-react";
3import {
4 AuthOrchestrationOperateScope,
5 AuthSourceControlWriteScope,
6 type ScopedThreadRef,
7} from "@t3tools/contracts";
8import {
9 isAtomCommandInterrupted,
10 squashAtomCommandFailure,
11} from "@t3tools/client-runtime/state/runtime";
12import type {
13 GitRunStackedActionResult,
14 GitStackedAction,
15 VcsStatusResult,
16} from "@t3tools/contracts";
17import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
18import {
19 CheckIcon,
20 ChevronDownIcon,
21 CloudDownloadIcon,
22 CloudUploadIcon,
23 FileDiffIcon,
24 GitCommitIcon,
25 InfoIcon,
26} from "lucide-react";
27import { Spinner } from "~/components/ui/spinner";
28import { cn } from "~/lib/utils";
29import { useOpenPrLink } from "~/lib/openPullRequestLink";
30import {
31 buildMenuItems,
32 formatGitActionElapsed,
33 GIT_ACTION_SUCCESS_VISIBLE_MS,
34 type GitActionProgressPresentation,
35 type GitActionIconName,
36 type GitActionMenuItem,
37 type GitQuickAction,
38 type DefaultBranchConfirmableAction,
39 requiresDefaultBranchConfirmation,
40 resolveDefaultBranchActionDialogCopy,
41 resolveGitActionProgressPresentation,
42 resolveGitActionResultToastTiming,
43 resolveLiveThreadBranchUpdate,
44 resolveThreadBranchMetadataPatch,
45 resolveQuickAction,
46 resolveThreadBranchUpdate,
47} from "./GitActionsControl.logic";
48import { StartTruncatedPath } from "./StartTruncatedPath";
49import { Button } from "~/components/ui/button";
50import { Checkbox } from "~/components/ui/checkbox";
51import {
52 Dialog,
53 DialogDescription,
54 DialogFooter,
55 DialogHeader,
56 DialogPanel,
57 DialogPopup,
58 DialogTitle,
59} from "~/components/ui/dialog";
60import { Group, GroupSeparator } from "~/components/ui/group";
61import {
62 Menu,
63 MenuItem,
64 MenuItemLabel,
65 MenuPopup,
66 MenuSub,
67 MenuSubTrigger,
68 MenuSubPopup,
69 MenuTrigger,
70} from "~/components/ui/menu";
71import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
72import { ScrollArea } from "~/components/ui/scroll-area";
73import { Textarea } from "~/components/ui/textarea";
74import { stackedThreadToast, toastManager } from "~/components/ui/toast";
75import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
76import { useOpenInPreferredEditor } from "~/editorPreferences";
77import {
78 useGitStackedAction,
79 useSourceControlActionRunning,
80 useVcsPullAction,
81} from "~/lib/sourceControlActions";
82import { useThreadProjection, useThreadShell } from "~/state/entities";
83import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
84import { useEnvironmentQuery } from "~/state/query";
85import { serverEnvironment } from "~/state/server";
86import { threadEnvironment } from "~/state/threads";
87import { useAtomCommand } from "~/state/use-atom-command";
88import { vcsActionManager, vcsEnvironment } from "~/state/vcs";
89import { randomUUID } from "~/lib/utils";
90import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
91import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
92import {
93 THREAD_DETAILS_PANEL_CHEVRON_CLASS,
94 THREAD_DETAILS_PANEL_ICON_CLASS,
95 THREAD_DETAILS_PANEL_LABEL_CLASS,
96 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
97 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
98} from "./chat/threadDetailsPanelStyles";
99import { getSourceControlPresentation } from "~/sourceControlPresentation";
100import { formatDiffCount } from "@t3tools/client-runtime/diff-count";
101
102interface GitActionsControlProps {
103 presentation?: "toolbar" | "menu";
104 gitCwd: string | null;
105 activeThreadRef: ScopedThreadRef | null;
106 draftId?: DraftId;
107 /**
108 * Opens the thread's own change request beside it. Absent when the thread has no project to
109 * place it against, in which case it still opens in the browser.
110 */
111 onOpenPullRequest?: ((number: number) => void) | undefined;
112 displayMode?: "toolbar" | "panel";
113 compact?: boolean;
114 onOpenChanges?: () => void;
115}
116
117interface PendingDefaultBranchAction {
118 action: DefaultBranchConfirmableAction;
119 branchName: string;
120 includesCommit: boolean;
121 commitMessage?: string;
122 onConfirmed?: () => void;
123 filePaths?: string[];
124}
125
126type GitActionToastId = ReturnType<typeof toastManager.add>;
127
128interface RunGitActionWithToastInput {
129 action: GitStackedAction;
130 commitMessage?: string;
131 onConfirmed?: () => void;
132 skipDefaultBranchPrompt?: boolean;
133 statusOverride?: VcsStatusResult | null;
134 featureBranch?: boolean;
135 filePaths?: string[];
136}
137
138interface InlineGitActionSuccess {
139 readonly title: string;
140 readonly description: string | null;
141 readonly scopeKey: string;
142}
143
144const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250;
145
146type RefreshVcsStatus = (target: {
147 readonly environmentId: ScopedThreadRef["environmentId"];
148 readonly input: { readonly cwd: string };
149}) => Promise<unknown>;
150
151function requestVcsStatusRefresh(
152 refresh: RefreshVcsStatus,
153 environmentId: ScopedThreadRef["environmentId"] | null,
154 cwd: string | null,
155): void {
156 if (environmentId === null || cwd === null) {
157 return;
158 }
159 void refresh({ environmentId, input: { cwd } });
160}
161const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const;
162
163/** Every built-in host, in the order the built-in definitions list them. */
164function getMenuActionDisabledReason({
165 item,
166 gitStatus,
167 isBusy,
168 hasPrimaryRemote,
169}: {
170 item: GitActionMenuItem;
171 gitStatus: VcsStatusResult | null;
172 isBusy: boolean;
173 hasPrimaryRemote: boolean;
174}): string | null {
175 if (!item.disabled) return null;
176 if (isBusy) return "Git action in progress.";
177 if (!gitStatus) return "Git status is unavailable.";
178
179 const hasBranch = gitStatus.refName !== null;
180 const hasChanges = gitStatus.hasWorkingTreeChanges;
181 const isAhead = gitStatus.aheadCount > 0;
182 const isBehind = gitStatus.behindCount > 0;
183 const terminology = getSourceControlPresentation(gitStatus.sourceControlProvider).terminology;
184
185 if (item.id === "commit") {
186 if (!hasChanges) {
187 return "Worktree is clean. Make changes before committing.";
188 }
189 return "Commit is currently unavailable.";
190 }
191
192 if (item.id === "push") {
193 if (!hasBranch) {
194 return "Detached HEAD: check out a branch before pushing.";
195 }
196 if (hasChanges) {
197 return "Commit or stash local changes before pushing.";
198 }
199 if (isBehind) {
200 return "Branch is behind upstream. Pull/rebase before pushing.";
201 }
202 if (!gitStatus.hasUpstream && !hasPrimaryRemote) {
203 return 'Add an "origin" remote before pushing.';
204 }
205 if (!isAhead) {
206 return "No local commits to push.";
207 }
208 return "Push is currently unavailable.";
209 }
210
211 if (!hasBranch) {
212 return `Detached HEAD: check out a branch before creating a ${terminology.singular}.`;
213 }
214 if (!gitStatus.hasUpstream && !hasPrimaryRemote) {
215 return `Add an "origin" remote before creating a ${terminology.singular}.`;
216 }
217 if ((gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) <= 0) {
218 return `No local commits to include in a ${terminology.singular}.`;
219 }
220 if (isBehind) {
221 return `Branch is behind upstream. Pull/rebase before creating a ${terminology.singular}.`;
222 }
223 return `Create ${terminology.singular} is currently unavailable.`;
224}
225
226const COMMIT_DIALOG_TITLE = "Commit changes";
227const COMMIT_DIALOG_DESCRIPTION =
228 "Review and confirm your commit. Leave the message blank to auto-generate one.";
229
230function GitActionItemIcon({
231 icon,
232 SourceControlIcon,
233}: {
234 icon: GitActionIconName;
235 SourceControlIcon: ReturnType<typeof getSourceControlPresentation>["Icon"];
236}) {
237 if (icon === "commit") return <GitCommitIcon />;
238 if (icon === "push") return <CloudUploadIcon />;
239 return <SourceControlIcon />;
240}
241
242function GitQuickActionIcon({
243 quickAction,
244 SourceControlIcon,
245 className = "size-3.5",
246}: {
247 quickAction: GitQuickAction;
248 className?: string;
249 SourceControlIcon: ReturnType<typeof getSourceControlPresentation>["Icon"];
250}) {
251 if (quickAction.kind === "run_pull") return <CloudDownloadIcon className={className} />;
252 if (quickAction.kind === "run_action") {
253 if (quickAction.action === "commit") return <GitCommitIcon className={className} />;
254 if (quickAction.action === "push" || quickAction.action === "commit_push") {
255 return <CloudUploadIcon className={className} />;
256 }
257 return <SourceControlIcon className={className} />;
258 }
259 if (quickAction.label === "Commit") return <GitCommitIcon className={className} />;
260 if (quickAction.label === "Push") return <CloudUploadIcon className={className} />;
261 return <InfoIcon className={className} />;
262}
263
264function GitActionElapsedTime({
265 startedAtMs,
266 className,
267}: {
268 startedAtMs: number | null;
269 className?: string;
270}) {
271 const [nowMs, setNowMs] = useState(() => Date.now());
272
273 useEffect(() => {
274 if (startedAtMs === null) {
275 return;
276 }
277
278 const intervalId = window.setInterval(() => {
279 setNowMs(Date.now());
280 }, 1_000);
281 return () => window.clearInterval(intervalId);
282 }, [startedAtMs]);
283
284 const elapsed = formatGitActionElapsed(startedAtMs, nowMs);
285 if (!elapsed) {
286 return null;
287 }
288
289 return <p className={className}>{elapsed}</p>;
290}
291
292function GitActionProgressButtonContent({
293 progress,
294 isPanel,
295}: {
296 progress: GitActionProgressPresentation;
297 isPanel: boolean;
298}) {
299 const hasOutput = progress.output !== null;
300
301 return (
302 <div
303 aria-atomic="false"
304 aria-live="polite"
305 className={cn(
306 "grid min-w-0 flex-1 items-center",
307 // Pin the title row to the button's minimum content height so output
308 // expands below it. Panel controls leave 24px after padding and border.
309 // No row gap: collapsed output must not add height. The panel's elapsed
310 // counter uses the menu-chevron slot, so it has no trailing column.
311 isPanel
312 ? "grid-cols-[auto_minmax(0,1fr)] grid-rows-[1.5rem] gap-x-3"
313 : "grid-cols-[auto_minmax(0,1fr)_auto] grid-rows-[1.25rem] gap-x-2.5 sm:grid-rows-[1rem]",
314 )}
315 role="status"
316 >
317 <Spinner aria-hidden="true" className="row-start-1 -mx-0.5 shrink-0" />
318 <p
319 className={cn(
320 "row-start-1 min-w-0 truncate text-left",
321 isPanel && THREAD_DETAILS_PANEL_LABEL_CLASS,
322 )}
323 >
324 {progress.status}
325 </p>
326 {!isPanel ? (
327 <GitActionElapsedTime
328 startedAtMs={progress.startedAtMs}
329 className="row-start-1 text-2xs font-normal tabular-nums text-muted-foreground"
330 />
331 ) : null}
332 <div
333 className={cn(
334 "col-start-2 grid min-w-0 transition-[grid-template-rows,opacity] duration-200 ease-out motion-reduce:transition-none",
335 !isPanel && "col-span-2",
336 hasOutput ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
337 )}
338 >
339 <div className="min-h-0 overflow-hidden">
340 <Tooltip>
341 <TooltipTrigger
342 render={
343 <p className="truncate pt-0.5 text-left text-2xs font-normal text-muted-foreground" />
344 }
345 >
346 {progress.output}
347 </TooltipTrigger>
348 <TooltipPopup side="bottom" className="max-w-96 break-words">
349 {progress.output}
350 </TooltipPopup>
351 </Tooltip>
352 </div>
353 </div>
354 </div>
355 );
356}
357
358function GitActionSuccessButtonContent({ success }: { success: InlineGitActionSuccess }) {
359 const hasDescription = success.description !== null;
360
361 return (
362 <div
363 aria-live="polite"
364 className="grid min-w-0 flex-1 grid-cols-[auto_minmax(0,1fr)] grid-rows-[1.5rem] items-center gap-x-3"
365 role="status"
366 >
367 <CheckIcon aria-hidden="true" className="size-3.5 shrink-0 text-success" />
368 <p className={cn("min-w-0 truncate text-left", THREAD_DETAILS_PANEL_LABEL_CLASS)}>
369 {success.title}
370 </p>
371 <div
372 className={cn(
373 "col-start-2 grid min-w-0 transition-[grid-template-rows,opacity] duration-200 ease-out motion-reduce:transition-none",
374 hasDescription ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
375 )}
376 >
377 <div className="min-h-0 overflow-hidden">
378 <Tooltip>
379 <TooltipTrigger
380 render={
381 <p className="truncate pt-0.5 text-left text-2xs font-normal text-muted-foreground" />
382 }
383 >
384 {success.description}
385 </TooltipTrigger>
386 <TooltipPopup side="bottom" className="max-w-96 break-words">
387 {success.description}
388 </TooltipPopup>
389 </Tooltip>
390 </div>
391 </div>
392 </div>
393 );
394}
395
396export default function GitActionsControl({
397 presentation = "toolbar",
398 gitCwd,
399 activeThreadRef,
400 draftId,
401 displayMode = "toolbar",
402 compact = false,
403 onOpenChanges,
404}: GitActionsControlProps) {
405 const isPanel = displayMode === "panel";
406 const ActionGroup = isPanel ? "div" : Group;
407 const panelAnchorRef = useRef<HTMLDivElement | null>(null);
408 const updateThreadMetadata = useAtomCommand(
409 threadEnvironment.updateMetadata,
410 "thread branch metadata update",
411 );
412 const activeEnvironmentId = activeThreadRef?.environmentId ?? null;
413 const successScopeKey = `${activeEnvironmentId ?? ""}\u0000${gitCwd ?? ""}`;
414 const canWriteSourceControl = useEnvironmentScope(
415 activeEnvironmentId,
416 AuthSourceControlWriteScope,
417 );
418 const canOperateThread = useEnvironmentScope(activeEnvironmentId, AuthOrchestrationOperateScope);
419 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(activeEnvironmentId));
420 const openInPreferredEditor = useOpenInPreferredEditor(
421 activeEnvironmentId,
422 serverConfig?.availableEditors ?? [],
423 );
424 const threadToastData = useMemo(
425 () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined),
426 [activeThreadRef],
427 );
428 const activeServerThreadShell = useThreadShell(activeThreadRef);
429 const openPrLink = useOpenPrLink(activeThreadRef ?? undefined);
430 const activeDraftThread = useComposerDraftStore((store) =>
431 draftId
432 ? store.getDraftSession(draftId)
433 : activeThreadRef
434 ? store.getDraftThreadByRef(activeThreadRef)
435 : null,
436 );
437 const activeServerThreadProjection = useThreadProjection(
438 activeDraftThread !== null && activeServerThreadShell === null ? null : activeThreadRef,
439 );
440 const activeServerThread =
441 activeServerThreadShell ?? activeServerThreadProjection?.projection.thread ?? null;
442 const isLocalDraftThread = activeDraftThread !== null && activeServerThread === null;
443 const canChangeThreadBranch = canWriteSourceControl && (isLocalDraftThread || canOperateThread);
444 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
445 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
446 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
447 const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());
448 const [isEditingFiles, setIsEditingFiles] = useState(false);
449 const [inlineSuccess, setInlineSuccess] = useState<InlineGitActionSuccess | null>(null);
450 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
451 useState<PendingDefaultBranchAction | null>(null);
452 const sourceControlScope = useMemo(
453 () => ({ environmentId: activeEnvironmentId, cwd: gitCwd }),
454 [activeEnvironmentId, gitCwd],
455 );
456 const vcsActionState = useAtomValue(vcsActionManager.stateAtom(sourceControlScope));
457 const visibleInlineSuccess = inlineSuccess?.scopeKey === successScopeKey ? inlineSuccess : null;
458
459 useEffect(() => {
460 if (!inlineSuccess) return;
461 const timeoutId = window.setTimeout(() => {
462 setInlineSuccess(null);
463 }, GIT_ACTION_SUCCESS_VISIBLE_MS);
464 return () => window.clearTimeout(timeoutId);
465 }, [inlineSuccess]);
466
467 const persistThreadBranchSync = useCallback(
468 (branch: string | null, manualSelection = false) => {
469 if (!activeThreadRef) {
470 return;
471 }
472
473 if (activeServerThread) {
474 if (
475 !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) ||
476 activeServerThread.branch === branch
477 ) {
478 return;
479 }
480
481 void updateThreadMetadata({
482 environmentId: activeThreadRef.environmentId,
483 input: {
484 threadId: activeThreadRef.threadId,
485 ...resolveThreadBranchMetadataPatch(branch, activeServerThread.branch),
486 },
487 });
488
489 return;
490 }
491
492 if (!activeDraftThread || activeDraftThread.branch === branch) {
493 return;
494 }
495
496 setDraftThreadContext(draftId ?? activeThreadRef, {
497 branch,
498 worktreePath: activeDraftThread.worktreePath,
499 environmentSelection: manualSelection
500 ? "manual"
501 : (activeDraftThread.environmentSelection ??
502 (activeDraftThread.branch ? "manual" : "auto")),
503 });
504 },
505 [
506 activeDraftThread,
507 activeServerThread,
508 activeThreadRef,
509 draftId,
510 setDraftThreadContext,
511 updateThreadMetadata,
512 ],
513 );
514
515 const syncThreadBranchAfterGitAction = useCallback(
516 (result: GitRunStackedActionResult) => {
517 const branchUpdate = resolveThreadBranchUpdate(result);
518 if (!branchUpdate) {
519 return;
520 }
521
522 persistThreadBranchSync(branchUpdate.branch, true);
523 },
524 [persistThreadBranchSync],
525 );
526
527 const gitStatusQuery = useEnvironmentQuery(
528 activeEnvironmentId !== null && gitCwd !== null
529 ? vcsEnvironment.status({
530 environmentId: activeEnvironmentId,
531 input: { cwd: gitCwd },
532 })
533 : null,
534 );
535 const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, {
536 reportFailure: false,
537 });
538 const { data: gitStatus, error: gitStatusError } = gitStatusQuery;
539 const sourceControlPresentation = useMemo(
540 () => getSourceControlPresentation(gitStatus?.sourceControlProvider),
541 [gitStatus?.sourceControlProvider],
542 );
543 const changeRequestTerminology = sourceControlPresentation.terminology;
544 const SourceControlIcon = sourceControlPresentation.Icon;
545 // Default to true while loading so we don't flash init controls.
546 const isRepo = gitStatus?.isRepo ?? true;
547 const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false;
548 const gitStatusForActions = gitStatus;
549 // Matches the diff panel's Changes view. Older servers only report uncommitted totals.
550 const changesTotals = gitStatusForActions?.branchChanges ?? gitStatusForActions?.workingTree;
551
552 const allFiles = gitStatusForActions?.workingTree.files ?? [];
553 const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path));
554 const allSelected = excludedFiles.size === 0;
555 const noneSelected = selectedFiles.length === 0;
556
557 const runImmediateGitAction = useGitStackedAction(sourceControlScope);
558 const pullAction = useVcsPullAction(sourceControlScope);
559 const isGitActionRunning = useSourceControlActionRunning(
560 sourceControlScope,
561 RUNNING_SOURCE_CONTROL_ACTIONS,
562 );
563 const isSelectingWorktreeBase =
564 !activeServerThread &&
565 activeDraftThread?.envMode === "worktree" &&
566 activeDraftThread.worktreePath === null;
567
568 useEffect(() => {
569 if (isGitActionRunning || isSelectingWorktreeBase || activeServerThread) {
570 return;
571 }
572
573 const branchUpdate = resolveLiveThreadBranchUpdate({
574 threadBranch: activeDraftThread?.branch ?? null,
575 gitStatus: gitStatusForActions,
576 });
577 if (!branchUpdate) {
578 return;
579 }
580
581 persistThreadBranchSync(branchUpdate.branch);
582 }, [
583 activeServerThread,
584 activeDraftThread?.branch,
585 gitStatusForActions,
586 isGitActionRunning,
587 isSelectingWorktreeBase,
588 persistThreadBranchSync,
589 ]);
590
591 const isDefaultRef = useMemo(() => {
592 return gitStatusForActions?.isDefaultRef ?? false;
593 }, [gitStatusForActions?.isDefaultRef]);
594
595 const gitActionMenuItems = useMemo(
596 () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasPrimaryRemote),
597 [gitStatusForActions, hasPrimaryRemote, isGitActionRunning],
598 );
599 const quickAction = useMemo(
600 () =>
601 resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultRef, hasPrimaryRemote),
602 [gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning],
603 );
604 const quickActionDisabledReason = !canWriteSourceControl
605 ? "This connection cannot change source control."
606 : quickAction.disabled
607 ? (quickAction.hint ?? "This action is currently unavailable.")
608 : null;
609 const gitActionProgress = resolveGitActionProgressPresentation(vcsActionState);
610 const pendingDefaultBranchActionCopy = pendingDefaultBranchAction
611 ? resolveDefaultBranchActionDialogCopy({
612 action: pendingDefaultBranchAction.action,
613 branchName: pendingDefaultBranchAction.branchName,
614 includesCommit: pendingDefaultBranchAction.includesCommit,
615 terminology: changeRequestTerminology,
616 })
617 : null;
618
619 useEffect(() => {
620 if (gitCwd === null) {
621 return;
622 }
623
624 let refreshTimeout: number | null = null;
625 const scheduleRefreshCurrentGitStatus = () => {
626 if (refreshTimeout !== null) {
627 window.clearTimeout(refreshTimeout);
628 }
629 refreshTimeout = window.setTimeout(() => {
630 refreshTimeout = null;
631 requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd);
632 }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS);
633 };
634 const handleVisibilityChange = () => {
635 if (document.visibilityState === "visible") {
636 scheduleRefreshCurrentGitStatus();
637 }
638 };
639
640 window.addEventListener("focus", scheduleRefreshCurrentGitStatus);
641 document.addEventListener("visibilitychange", handleVisibilityChange);
642
643 return () => {
644 if (refreshTimeout !== null) {
645 window.clearTimeout(refreshTimeout);
646 }
647 window.removeEventListener("focus", scheduleRefreshCurrentGitStatus);
648 document.removeEventListener("visibilitychange", handleVisibilityChange);
649 };
650 }, [activeEnvironmentId, gitCwd, refreshVcsStatus]);
651
652 // React 19.2 leaves useEffectEvent stale inside the memoized details panel.
653 // Bind each render's actions to its checkout and thread, including retained toast actions.
654 const runGitActionWithToast = useCallback(
655 async function runGitActionWithToast({
656 action,
657 commitMessage,
658 onConfirmed,
659 skipDefaultBranchPrompt = false,
660 statusOverride,
661 featureBranch = false,
662 filePaths,
663 }: RunGitActionWithToastInput) {
664 if (
665 activeEnvironmentId === null ||
666 !readEnvironmentScope(activeEnvironmentId, AuthSourceControlWriteScope) ||
667 (featureBranch &&
668 !isLocalDraftThread &&
669 !readEnvironmentScope(activeEnvironmentId, AuthOrchestrationOperateScope))
670 )
671 return;
672 const actionStatus = statusOverride ?? gitStatusForActions;
673 const actionBranch = actionStatus?.refName ?? null;
674 const actionIsDefaultBranch = featureBranch ? false : isDefaultRef;
675 const actionCanCommit =
676 action === "commit" || action === "commit_push" || action === "commit_push_pr";
677 const includesCommit =
678 actionCanCommit &&
679 (action === "commit" || !!actionStatus?.hasWorkingTreeChanges || featureBranch);
680 if (
681 !skipDefaultBranchPrompt &&
682 requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) &&
683 actionBranch
684 ) {
685 if (
686 action !== "push" &&
687 action !== "create_pr" &&
688 action !== "commit_push" &&
689 action !== "commit_push_pr"
690 ) {
691 return;
692 }
693 setPendingDefaultBranchAction({
694 action,
695 branchName: actionBranch,
696 includesCommit,
697 ...(commitMessage ? { commitMessage } : {}),
698 ...(onConfirmed ? { onConfirmed } : {}),
699 ...(filePaths ? { filePaths } : {}),
700 });
701 return;
702 }
703 onConfirmed?.();
704 setInlineSuccess(null);
705
706 const scopedToastData = threadToastData ? { ...threadToastData } : undefined;
707 const actionId = randomUUID();
708
709 const result = await runImmediateGitAction.run({
710 actionId,
711 action,
712 ...(commitMessage ? { commitMessage } : {}),
713 ...(featureBranch ? { featureBranch } : {}),
714 ...(filePaths ? { filePaths } : {}),
715 // A pull request the action opens is linked to the thread it ran beside. Drafts
716 // have no server thread yet, so there is nothing to link to.
717 ...(activeServerThread ? { threadId: activeServerThread.id } : {}),
718 ...(activeDraftThread ? { projectId: activeDraftThread.projectId } : {}),
719 });
720
721 if (result._tag === "Failure") {
722 if (isAtomCommandInterrupted(result)) {
723 return;
724 }
725
726 const error = squashAtomCommandFailure(result);
727 const errorToastTiming = resolveGitActionResultToastTiming("error");
728 toastManager.add(
729 stackedThreadToast({
730 type: "error",
731 title: "Action failed",
732 description: error instanceof Error ? error.message : "An error occurred.",
733 timeout: errorToastTiming.timeout,
734 ...(scopedToastData !== undefined ? { data: scopedToastData } : {}),
735 }),
736 );
737 return;
738 }
739
740 const actionResult = result.value;
741 syncThreadBranchAfterGitAction(actionResult);
742 if (isPanel) {
743 setInlineSuccess({
744 title: actionResult.toast.title,
745 description: actionResult.toast.description ?? null,
746 scopeKey: successScopeKey,
747 });
748 return;
749 }
750 let resultToastId: GitActionToastId | null = null;
751 const closeResultToast = () => {
752 if (resultToastId !== null) {
753 toastManager.close(resultToastId);
754 }
755 };
756
757 const toastCta = actionResult.toast.cta;
758 let toastActionProps: {
759 children: string;
760 onClick: (event: MouseEvent<HTMLButtonElement>) => void;
761 } | null = null;
762 if (toastCta.kind === "run_action") {
763 toastActionProps = {
764 children: toastCta.label,
765 onClick: () => {
766 closeResultToast();
767 void runGitActionWithToast({
768 action: toastCta.action.kind,
769 });
770 },
771 };
772 } else if (toastCta.kind === "open_pr") {
773 toastActionProps = {
774 children: toastCta.label,
775 onClick: (event) => {
776 closeResultToast();
777 openPrLink(event, toastCta.url);
778 },
779 };
780 }
781
782 const successToastTiming = resolveGitActionResultToastTiming("success");
783 const successToastData = {
784 ...scopedToastData,
785 ...(successToastTiming.dismissAfterVisibleMs !== null
786 ? { dismissAfterVisibleMs: successToastTiming.dismissAfterVisibleMs }
787 : {}),
788 };
789
790 if (toastActionProps) {
791 resultToastId = toastManager.add(
792 stackedThreadToast({
793 type: "success",
794 title: actionResult.toast.title,
795 description: actionResult.toast.description,
796 timeout: successToastTiming.timeout,
797 actionProps: toastActionProps,
798 data: successToastData,
799 }),
800 );
801 } else {
802 resultToastId = toastManager.add({
803 type: "success",
804 title: actionResult.toast.title,
805 description: actionResult.toast.description,
806 timeout: successToastTiming.timeout,
807 data: successToastData,
808 });
809 }
810 },
811 [
812 activeDraftThread,
813 activeEnvironmentId,
814 activeServerThread,
815 gitStatusForActions,
816 isDefaultRef,
817 isLocalDraftThread,
818 isPanel,
819 openPrLink,
820 runImmediateGitAction,
821 successScopeKey,
822 syncThreadBranchAfterGitAction,
823 threadToastData,
824 ],
825 );
826
827 const continuePendingDefaultBranchAction = () => {
828 if (!pendingDefaultBranchAction) return;
829 const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction;
830 setPendingDefaultBranchAction(null);
831 void runGitActionWithToast({
832 action,
833 ...(commitMessage ? { commitMessage } : {}),
834 ...(onConfirmed ? { onConfirmed } : {}),
835 ...(filePaths ? { filePaths } : {}),
836 skipDefaultBranchPrompt: true,
837 });
838 };
839
840 const checkoutFeatureBranchAndContinuePendingAction = () => {
841 if (!canChangeThreadBranch || !pendingDefaultBranchAction) return;
842 const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction;
843 setPendingDefaultBranchAction(null);
844 void runGitActionWithToast({
845 action,
846 ...(commitMessage ? { commitMessage } : {}),
847 ...(onConfirmed ? { onConfirmed } : {}),
848 ...(filePaths ? { filePaths } : {}),
849 featureBranch: true,
850 skipDefaultBranchPrompt: true,
851 });
852 };
853
854 const runDialogActionOnNewBranch = () => {
855 if (!canChangeThreadBranch || !isCommitDialogOpen) return;
856 const commitMessage = dialogCommitMessage.trim();
857
858 setIsCommitDialogOpen(false);
859 setDialogCommitMessage("");
860 setExcludedFiles(new Set());
861 setIsEditingFiles(false);
862
863 void runGitActionWithToast({
864 action: "commit",
865 ...(commitMessage ? { commitMessage } : {}),
866 ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}),
867 featureBranch: true,
868 skipDefaultBranchPrompt: true,
869 });
870 };
871
872 const runQuickAction = () => {
873 if (quickAction.kind === "run_pull") {
874 void (async () => {
875 setInlineSuccess(null);
876 const result = await pullAction.run();
877 if (result._tag === "Failure") {
878 if (isAtomCommandInterrupted(result)) {
879 return;
880 }
881 const error = squashAtomCommandFailure(result);
882 const errorToastTiming = resolveGitActionResultToastTiming("error");
883 toastManager.add(
884 stackedThreadToast({
885 type: "error",
886 title: "Pull failed",
887 description: error instanceof Error ? error.message : "An error occurred.",
888 timeout: errorToastTiming.timeout,
889 ...(threadToastData !== undefined ? { data: threadToastData } : {}),
890 }),
891 );
892 return;
893 }
894
895 const pullResult = result.value;
896 const title = pullResult.status === "pulled" ? "Pulled" : "Already up to date";
897 const description =
898 pullResult.status === "pulled"
899 ? `Updated ${pullResult.refName} from ${pullResult.upstreamRef ?? "upstream"}`
900 : `${pullResult.refName} is already synchronized.`;
901 if (isPanel) {
902 setInlineSuccess({ title, description, scopeKey: successScopeKey });
903 return;
904 }
905 const successToastTiming = resolveGitActionResultToastTiming("success");
906 toastManager.add({
907 type: "success",
908 title,
909 description,
910 timeout: successToastTiming.timeout,
911 data: {
912 ...threadToastData,
913 ...(successToastTiming.dismissAfterVisibleMs !== null
914 ? { dismissAfterVisibleMs: successToastTiming.dismissAfterVisibleMs }
915 : {}),
916 },
917 });
918 })();
919 return;
920 }
921 if (quickAction.kind === "show_hint") {
922 toastManager.add({
923 type: "info",
924 title: quickAction.label,
925 description: quickAction.hint,
926 data: threadToastData,
927 });
928 return;
929 }
930 if (quickAction.action) {
931 void runGitActionWithToast({ action: quickAction.action });
932 }
933 };
934
935 const openDialogForMenuItem = (item: GitActionMenuItem) => {
936 if (item.disabled) return;
937 if (item.dialogAction === "push") {
938 void runGitActionWithToast({ action: "push" });
939 return;
940 }
941 if (item.dialogAction === "create_pr") {
942 void runGitActionWithToast({ action: "create_pr" });
943 return;
944 }
945 setExcludedFiles(new Set());
946 setIsEditingFiles(false);
947 setIsCommitDialogOpen(true);
948 };
949
950 const runDialogAction = () => {
951 if (!isCommitDialogOpen) return;
952 const commitMessage = dialogCommitMessage.trim();
953 setIsCommitDialogOpen(false);
954 setDialogCommitMessage("");
955 setExcludedFiles(new Set());
956 setIsEditingFiles(false);
957 void runGitActionWithToast({
958 action: "commit",
959 ...(commitMessage ? { commitMessage } : {}),
960 ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}),
961 });
962 };
963
964 const openChangedFileInEditor = useCallback(
965 (filePath: string) => {
966 if (!gitCwd) {
967 toastManager.add({
968 type: "error",
969 title: "Editor opening is unavailable.",
970 data: threadToastData,
971 });
972 return;
973 }
974 const target = resolvePathLinkTarget(filePath, gitCwd);
975 void (async () => {
976 const result = await openInPreferredEditor(target);
977 if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
978 return;
979 }
980 const error = squashAtomCommandFailure(result);
981 toastManager.add(
982 stackedThreadToast({
983 type: "error",
984 title: "Unable to open file",
985 description: error instanceof Error ? error.message : "An error occurred.",
986 ...(threadToastData !== undefined ? { data: threadToastData } : {}),
987 }),
988 );
989 })();
990 },
991 [gitCwd, openInPreferredEditor, threadToastData],
992 );
993
994 const gitItems = (
995 <>
996 {gitActionMenuItems.map((item) => {
997 const disabledReason = getMenuActionDisabledReason({
998 item,
999 gitStatus: gitStatusForActions,
1000 isBusy: isGitActionRunning,
1001 hasPrimaryRemote,
1002 });
1003 if (item.disabled && disabledReason && presentation === "menu") {
1004 return (
1005 <div key={`${item.id}-${item.label}`}>
1006 <MenuItem density={presentation === "menu" ? "touch" : "default"} disabled>
1007 <GitActionItemIcon icon={item.icon} SourceControlIcon={SourceControlIcon} />
1008 <MenuItemLabel>{item.label}</MenuItemLabel>
1009 </MenuItem>
1010 <p className="max-w-64 px-2 pb-2 text-xs text-muted-foreground">{disabledReason}</p>
1011 </div>
1012 );
1013 }
1014 if (item.disabled && disabledReason) {
1015 return (
1016 <Popover key={`${item.id}-${item.label}`}>
1017 <PopoverTrigger
1018 openOnHover
1019 nativeButton={false}
1020 render={<span className="block w-full cursor-not-allowed" />}
1021 >
1022 <MenuItem
1023 density={presentation === "menu" ? "touch" : "default"}
1024 className="w-full"
1025 disabled
1026 >
1027 <GitActionItemIcon icon={item.icon} SourceControlIcon={SourceControlIcon} />
1028 <MenuItemLabel>{item.label}</MenuItemLabel>
1029 </MenuItem>
1030 </PopoverTrigger>
1031 <PopoverPopup tooltipStyle side="left" align="center">
1032 {disabledReason}
1033 </PopoverPopup>
1034 </Popover>
1035 );
1036 }
1037
1038 return (
1039 <MenuItem
1040 density={presentation === "menu" ? "touch" : "default"}
1041 key={`${item.id}-${item.label}`}
1042 disabled={!canWriteSourceControl || item.disabled}
1043 onClick={() => {
1044 openDialogForMenuItem(item);
1045 }}
1046 >
1047 <GitActionItemIcon icon={item.icon} SourceControlIcon={SourceControlIcon} />
1048 <MenuItemLabel>{item.label}</MenuItemLabel>
1049 </MenuItem>
1050 );
1051 })}
1052 {gitStatusForActions?.refName === null && (
1053 <p className="px-2 py-1.5 text-xs text-warning">
1054 Detached HEAD: create and check out a branch to enable push and pull request actions.
1055 </p>
1056 )}
1057 {gitStatusForActions &&
1058 gitStatusForActions.refName !== null &&
1059 !gitStatusForActions.hasWorkingTreeChanges &&
1060 gitStatusForActions.behindCount > 0 &&
1061 gitStatusForActions.aheadCount === 0 && (
1062 <p className="px-2 py-1.5 text-xs text-warning">Behind upstream. Pull/rebase first.</p>
1063 )}
1064 {gitStatusError && <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError}</p>}
1065 </>
1066 );
1067
1068 const changesControl = isPanel ? (
1069 <ThreadDetailsControl
1070 type="button"
1071 variant="ghost"
1072 size="sm"
1073 part="row"
1074 disabled={!onOpenChanges}
1075 onClick={onOpenChanges}
1076 >
1077 <FileDiffIcon className={THREAD_DETAILS_PANEL_ICON_CLASS} aria-hidden />
1078 <span className={cn("flex-1 text-left", THREAD_DETAILS_PANEL_LABEL_CLASS)}>Changes</span>
1079 {isRepo ? (
1080 <span className="flex items-center gap-1 font-mono text-2xs tabular-nums">
1081 <span className="text-success">+{formatDiffCount(changesTotals?.insertions ?? 0)}</span>
1082 <span className="text-destructive">
1083 -{formatDiffCount(changesTotals?.deletions ?? 0)}
1084 </span>
1085 </span>
1086 ) : null}
1087 </ThreadDetailsControl>
1088 ) : null;
1089
1090 if (!gitCwd) return null;
1091 if (!isRepo) return onOpenChanges ? changesControl : null;
1092
1093 return (
1094 <>
1095 {presentation === "menu" ? (
1096 <>
1097 <MenuItem
1098 density={presentation === "menu" ? "touch" : "default"}
1099
1100 disabled={isGitActionRunning || quickAction.disabled || !!quickActionDisabledReason}
1101 onClick={runQuickAction}
1102 >
1103 <GitQuickActionIcon
1104 className="size-4"
1105 quickAction={quickAction}
1106 SourceControlIcon={SourceControlIcon}
1107 />
1108 <MenuItemLabel>{quickAction.label}</MenuItemLabel>
1109 </MenuItem>
1110 {quickActionDisabledReason && (
1111 <p className="max-w-64 px-2 py-1.5 text-xs text-warning">{quickActionDisabledReason}</p>
1112 )}
1113 <MenuSub
1114 onOpenChange={(open) => {
1115 if (open) requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd);
1116 }}
1117 >
1118 <MenuSubTrigger density="touch" disabled={isGitActionRunning}>
1119 <SourceControlIcon className="size-4" />
1120 <MenuItemLabel>Git actions</MenuItemLabel>
1121 </MenuSubTrigger>
1122 <MenuSubPopup>{gitItems}</MenuSubPopup>
1123 </MenuSub>
1124 </>
1125 ) : compact && !gitActionProgress && !visibleInlineSuccess ? null : (
1126 <ActionGroup
1127 role="group"
1128 aria-label="Git actions"
1129 {...(isPanel ? { ref: panelAnchorRef } : {})}
1130 className={cn(
1131 "shrink-0",
1132 isPanel && THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
1133 isPanel && gitActionProgress && "bg-black/[0.035] dark:bg-white/[0.055]",
1134 )}
1135 >
1136 {gitActionProgress ? (
1137 <ThreadDetailsControl
1138 aria-label={
1139 gitActionProgress.output
1140 ? `${gitActionProgress.status} ${gitActionProgress.output}`
1141 : gitActionProgress.status
1142 }
1143 part="primary"
1144 panel={isPanel}
1145 multiline
1146 className={isPanel ? undefined : "max-w-72"}
1147 disabled
1148 size="xs"
1149 variant={isPanel ? "ghost" : "outline"}
1150 >
1151 <GitActionProgressButtonContent isPanel={isPanel} progress={gitActionProgress} />
1152 </ThreadDetailsControl>
1153 ) : isPanel && visibleInlineSuccess ? (
1154 <ThreadDetailsControl part="primary" multiline disabled size="xs" variant="ghost">
1155 <GitActionSuccessButtonContent success={visibleInlineSuccess} />
1156 </ThreadDetailsControl>
1157 ) : quickActionDisabledReason ? (
1158 <Popover>
1159 <PopoverTrigger
1160 openOnHover
1161 render={
1162 <ThreadDetailsControl
1163 aria-disabled="true"
1164 part="primary"
1165 panel={isPanel}
1166 size="xs"
1167 variant={isPanel ? "ghost" : "outline"}
1168 />
1169 }
1170 >
1171 <GitQuickActionIcon
1172 quickAction={quickAction}
1173 SourceControlIcon={SourceControlIcon}
1174 {...(isPanel ? { className: THREAD_DETAILS_PANEL_ICON_CLASS } : {})}
1175 />
1176 <span
1177 className={cn(
1178 "sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5",
1179 isPanel && cn("not-sr-only ml-0 truncate", THREAD_DETAILS_PANEL_LABEL_CLASS),
1180 )}
1181 >
1182 {quickAction.label}
1183 </span>
1184 </PopoverTrigger>
1185 <PopoverPopup tooltipStyle side="bottom" align="start">
1186 {quickActionDisabledReason}
1187 </PopoverPopup>
1188 </Popover>
1189 ) : (
1190 <ThreadDetailsControl
1191 variant={isPanel ? "ghost" : "outline"}
1192 size="xs"
1193 part="primary"
1194 panel={isPanel}
1195 disabled={!canWriteSourceControl || isGitActionRunning || quickAction.disabled}
1196 onClick={runQuickAction}
1197 >
1198 <GitQuickActionIcon
1199 quickAction={quickAction}
1200 SourceControlIcon={SourceControlIcon}
1201 {...(isPanel ? { className: THREAD_DETAILS_PANEL_ICON_CLASS } : {})}
1202 />
1203 <span
1204 className={cn(
1205 "sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5",
1206 isPanel && cn("not-sr-only ml-0 truncate", THREAD_DETAILS_PANEL_LABEL_CLASS),
1207 )}
1208 >
1209 {quickAction.label}
1210 </span>
1211 </ThreadDetailsControl>
1212 )}
1213 {isPanel && gitActionProgress ? (
1214 // The menu is disabled while an action runs, so its chevron slot
1215 // hosts the elapsed counter instead, leaving the full row width to
1216 // the status text. Pinned to the title row so it stays put when
1217 // the output row expands below.
1218 <GitActionElapsedTime
1219 startedAtMs={gitActionProgress.startedAtMs}
1220 className="flex h-8 shrink-0 items-center self-start pe-2.5 text-2xs font-normal tabular-nums text-muted-foreground"
1221 />
1222 ) : (
1223 <>
1224 {isPanel ? (
1225 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
1226 ) : (
1227 <GroupSeparator className="hidden @3xl/header-actions:block" />
1228 )}
1229 <Menu
1230 onOpenChange={(open) => {
1231 if (open) {
1232 requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd);
1233 }
1234 }}
1235 >
1236 <MenuTrigger
1237 render={
1238 <ThreadDetailsControl
1239 aria-label="Git action options"
1240 size={isPanel ? "sm" : "icon-xs"}
1241 variant={isPanel ? "ghost" : "outline"}
1242 part="secondary"
1243 panel={isPanel}
1244 />
1245 }
1246 disabled={isGitActionRunning}
1247 >
1248 <ChevronDownIcon
1249 aria-hidden="true"
1250 className={isPanel ? THREAD_DETAILS_PANEL_CHEVRON_CLASS : "size-4"}
1251 />
1252 </MenuTrigger>
1253 <MenuPopup
1254 align="end"
1255 {...(isPanel ? { anchor: panelAnchorRef } : {})}
1256 className={isPanel ? "w-(--anchor-width)" : "w-full"}
1257 >
1258 {gitItems}
1259 </MenuPopup>
1260 </Menu>
1261 </>
1262 )}
1263 </ActionGroup>
1264 )}
1265
1266 {changesControl}
1267
1268 <Dialog
1269 open={isCommitDialogOpen}
1270 onOpenChange={(open) => {
1271 if (!open) {
1272 setIsCommitDialogOpen(false);
1273 setDialogCommitMessage("");
1274 setExcludedFiles(new Set());
1275 setIsEditingFiles(false);
1276 }
1277 }}
1278 >
1279 <DialogPopup>
1280 <DialogHeader>
1281 <DialogTitle>{COMMIT_DIALOG_TITLE}</DialogTitle>
1282 <DialogDescription>{COMMIT_DIALOG_DESCRIPTION}</DialogDescription>
1283 </DialogHeader>
1284 <DialogPanel>
1285 <div className="space-y-3 rounded-xl bg-zinc-25 p-3 text-sm ring-1 ring-black/5 dark:bg-white/[0.035] dark:ring-white/5">
1286 <div className="grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-1">
1287 <span className="text-muted-foreground">Branch</span>
1288 <span className="flex items-center justify-between gap-2">
1289 <span className="font-medium">
1290 {gitStatusForActions?.refName ?? "(detached HEAD)"}
1291 </span>
1292 {isDefaultRef && <span className="text-right text-warning">Default branch</span>}
1293 </span>
1294 </div>
1295 <div className="space-y-1">
1296 <div className="flex items-center justify-between">
1297 <div className="flex items-center gap-2">
1298 {isEditingFiles && allFiles.length > 0 && (
1299 <Checkbox
1300 checked={allSelected}
1301 indeterminate={!allSelected && !noneSelected}
1302 onCheckedChange={() => {
1303 setExcludedFiles(
1304 allSelected ? new Set(allFiles.map((f) => f.path)) : new Set(),
1305 );
1306 }}
1307 />
1308 )}
1309 <span className="text-muted-foreground">Files</span>
1310 {!allSelected && !isEditingFiles && (
1311 <span className="text-muted-foreground">
1312 ({selectedFiles.length} of {allFiles.length})
1313 </span>
1314 )}
1315 </div>
1316 {allFiles.length > 0 && (
1317 <Button
1318 variant="ghost"
1319 size="xs"
1320 onClick={() => setIsEditingFiles((prev) => !prev)}
1321 >
1322 {isEditingFiles ? "Done" : "Edit"}
1323 </Button>
1324 )}
1325 </div>
1326 {!gitStatusForActions || allFiles.length === 0 ? (
1327 <p className="font-medium">none</p>
1328 ) : (
1329 <div className="space-y-2">
1330 <div className="h-44 rounded-lg bg-card ring-1 ring-black/5 dark:bg-white/[0.025] dark:ring-white/5">
1331 <ScrollArea>
1332 <div className="space-y-1 p-1">
1333 {allFiles.map((file) => {
1334 const isExcluded = excludedFiles.has(file.path);
1335 return (
1336 <div
1337 key={file.path}
1338 className="flex w-full items-center gap-2 rounded-md px-2 py-1 font-mono hover:bg-accent/50"
1339 >
1340 {isEditingFiles && (
1341 <Checkbox
1342 checked={!excludedFiles.has(file.path)}
1343 onCheckedChange={() => {
1344 setExcludedFiles((prev) => {
1345 const next = new Set(prev);
1346 if (next.has(file.path)) {
1347 next.delete(file.path);
1348 } else {
1349 next.add(file.path);
1350 }
1351 return next;
1352 });
1353 }}
1354 />
1355 )}
1356 <button
1357 type="button"
1358 className="flex min-w-0 flex-1 items-center justify-between gap-3 text-left"
1359 disabled={!canOperateThread}
1360 aria-label={`Open ${file.path} in editor`}
1361 onClick={() => openChangedFileInEditor(file.path)}
1362 >
1363 <StartTruncatedPath
1364 path={file.path}
1365 className={`flex-1${isExcluded ? " text-muted-foreground" : ""}`}
1366 />
1367 <span className="shrink-0">
1368 {isExcluded ? (
1369 <span className="text-muted-foreground">Excluded</span>
1370 ) : (
1371 <>
1372 <span className="text-diff-addition">
1373 +{formatDiffCount(file.insertions)}
1374 </span>
1375 <span className="text-muted-foreground"> / </span>
1376 <span className="text-diff-deletion">
1377 -{formatDiffCount(file.deletions)}
1378 </span>
1379 </>
1380 )}
1381 </span>
1382 </button>
1383 </div>
1384 );
1385 })}
1386 </div>
1387 </ScrollArea>
1388 </div>
1389 <div className="flex justify-end font-mono">
1390 <span className="text-diff-addition">
1391 +{formatDiffCount(selectedFiles.reduce((sum, f) => sum + f.insertions, 0))}
1392 </span>
1393 <span className="text-muted-foreground"> / </span>
1394 <span className="text-diff-deletion">
1395 -{formatDiffCount(selectedFiles.reduce((sum, f) => sum + f.deletions, 0))}
1396 </span>
1397 </div>
1398 </div>
1399 )}
1400 </div>
1401 </div>
1402 <div className="space-y-1">
1403 <p className="text-sm font-medium">Commit message (optional)</p>
1404 <Textarea
1405 value={dialogCommitMessage}
1406 onChange={(event) => setDialogCommitMessage(event.target.value)}
1407 placeholder="Leave empty to auto-generate"
1408 size="sm"
1409 />
1410 </div>
1411 </DialogPanel>
1412 <DialogFooter variant="bare">
1413 <Button
1414 variant="outline"
1415 size="sm"
1416 onClick={() => {
1417 setIsCommitDialogOpen(false);
1418 setDialogCommitMessage("");
1419 setExcludedFiles(new Set());
1420 setIsEditingFiles(false);
1421 }}
1422 >
1423 Cancel
1424 </Button>
1425 <Button
1426 variant="outline"
1427 size="sm"
1428 disabled={!canChangeThreadBranch || noneSelected}
1429 onClick={runDialogActionOnNewBranch}
1430 >
1431 Commit on new branch
1432 </Button>
1433 <Button
1434 size="sm"
1435 disabled={!canWriteSourceControl || noneSelected}
1436 onClick={runDialogAction}
1437 >
1438 Commit
1439 </Button>
1440 </DialogFooter>
1441 </DialogPopup>
1442 </Dialog>
1443
1444 <Dialog
1445 open={pendingDefaultBranchAction !== null}
1446 onOpenChange={(open) => {
1447 if (!open) {
1448 setPendingDefaultBranchAction(null);
1449 }
1450 }}
1451 >
1452 <DialogPopup className="max-w-xl">
1453 <DialogHeader>
1454 <DialogTitle>
1455 {pendingDefaultBranchActionCopy?.title ?? "Run action on default branch?"}
1456 </DialogTitle>
1457 <DialogDescription>{pendingDefaultBranchActionCopy?.description}</DialogDescription>
1458 </DialogHeader>
1459 <DialogFooter variant="bare" className="sm:flex-wrap sm:items-center">
1460 <Button
1461 className="w-full sm:mr-auto sm:w-auto"
1462 variant="outline"
1463 size="sm"
1464 onClick={() => setPendingDefaultBranchAction(null)}
1465 >
1466 Abort
1467 </Button>
1468 <Button
1469 className="w-full max-w-full sm:w-auto"
1470 variant="outline"
1471 size="sm-multiline"
1472 onClick={continuePendingDefaultBranchAction}
1473 disabled={!canWriteSourceControl}
1474 >
1475 {pendingDefaultBranchActionCopy?.continueLabel ?? "Continue"}
1476 </Button>
1477 <Button
1478 className="w-full max-w-full sm:w-auto"
1479 size="sm-multiline"
1480 onClick={checkoutFeatureBranchAndContinuePendingAction}
1481 disabled={!canChangeThreadBranch}
1482 >
1483 Check out feature branch & continue
1484 </Button>
1485 </DialogFooter>
1486 </DialogPopup>
1487 </Dialog>
1488 </>
1489 );
1490}