feat(ui): unify change comparisons and compact message actions

Branch comparisons could retain an old base or omit local edits, while Changes and walkthrough selected their sources independently.

Share branch and commit selectors across both panels, honor exact refs, include local branch edits, and support first-parent commit diffs with the latest 50 commits. Compact message metadata and move touch actions into a shared sheet.

Validated with workspace type-check, lint and build, focused Git and UI tests, and maintainer testing in the app.
This commit is contained in:
Bohdan Triapitsyn
2026-09-09 17:41:14 +03:00
parent ec7db447bd
commit 0b899d1153
45 changed files with 2131 additions and 599 deletions
@@ -26,6 +26,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { flattenAssistantTextParts, suggestPlanTitleFromText } from '@/lib/messages/messageText';
import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executionMeta';
import { useMessageTTS } from '@/hooks/useMessageTTS';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { useConfigStore } from '@/stores/useConfigStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { TextSelectionMenu } from './TextSelectionMenu';
@@ -470,7 +471,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
alwaysShowActions?: boolean;
hasTouchInput?: boolean;
hasTextContent?: boolean;
onCopyMessage?: () => void;
onCopyMessage?: () => void | boolean | Promise<void | boolean>;
copiedMessage?: boolean;
onShowPopup: (content: ToolPopupContent) => void;
agentMention?: AgentMentionInfo;
@@ -573,6 +574,51 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
);
const effectiveOnFork = chatSurfaceMode === 'mini-chat' ? undefined : onFork;
const [userActionSheetOpen, setUserActionSheetOpen] = React.useState(false);
const userSheetActions = React.useMemo(() => {
const actions: Array<{ id: string; label: string; icon: React.ReactNode; disabled?: boolean; onSelect: () => void }> = [];
if (canCopyMessage && hasCopyableText && onCopyMessage) {
actions.push({
id: 'copy',
label: t('chat.messageBody.actions.copyMessage'),
icon: <Icon name="file-copy" className="h-4 w-4" />,
// The sheet closes on tap, so the button's own tick has nowhere
// to land — say it with a toast instead.
onSelect: () => {
void (async () => {
const copied = await onCopyMessage();
if (copied !== false) toast.success(t('chat.messageBody.toast.copied'));
})();
},
});
}
if (onToggleContextPin && hasCopyableText) {
actions.push({
id: 'pin-context',
label: t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext'),
icon: <Icon name={contextPinned ? 'pushpin-2-fill' : 'pushpin-2'} className="h-4 w-4" />,
disabled: contextPinPending,
onSelect: () => { onToggleContextPin(); },
});
}
if (effectiveOnFork) {
actions.push({
id: 'fork',
label: t('chat.messageBody.actions.fork'),
icon: <Icon name="git-branch" className="h-4 w-4" />,
onSelect: () => { effectiveOnFork(); },
});
}
if (onRevert) {
actions.push({
id: 'revert',
label: t('chat.messageBody.actions.revert'),
icon: <Icon name="arrow-go-back" className="h-4 w-4" />,
onSelect: () => { onRevert(); },
});
}
return actions;
}, [canCopyMessage, contextPinPending, contextPinned, effectiveOnFork, hasCopyableText, onCopyMessage, onRevert, onToggleContextPin, t]);
const timestamp = React.useMemo(() => {
void locale;
if (typeof messageCreatedAt !== 'number' || messageCreatedAt <= 0) return null;
@@ -607,7 +653,8 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
: 'pointer-events-none opacity-0 transition-opacity duration-150 group-hover/message:pointer-events-auto group-hover/message:opacity-100 group-hover/user-actions:pointer-events-auto group-hover/user-actions:opacity-100 group-hover/user-shell:pointer-events-auto group-hover/user-shell:opacity-100'
)}
>
{timestamp ? (
{/* Touch reads the time in the actions sheet instead — see below. */}
{timestamp && !alwaysShowActions ? (
<Tooltip>
<TooltipTrigger asChild>
<span
@@ -615,106 +662,161 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
aria-label={`Message time: ${timestamp}`}
>
<Icon name="time" className="h-3.5 w-3.5" />
<span className="message-footer__label">{timestamp}</span>
<span>{timestamp}</span>
</span>
</TooltipTrigger>
<TooltipContent>{timestamp}</TooltipContent>
</Tooltip>
) : null}
{onRevert && (
<Tooltip>
<TooltipTrigger asChild>
{/* Touch has no hover, so the row would stand open under every
message. One button and a labelled sheet instead — the same
shape the assistant footer uses. */}
{alwaysShowActions ? (
<>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('chat.messageBody.actions.revertAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onRevert();
}}
>
<Icon name="arrow-go-back" className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.revert')}</TooltipContent>
</Tooltip>
)}
{effectiveOnFork && (
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('chat.messageBody.actions.moreActions')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
setUserActionSheetOpen(true);
}}
>
<Icon name="more" className="h-3.5 w-3.5" />
</Button>
<MobileOverlayPanel
open={userActionSheetOpen}
onClose={() => setUserActionSheetOpen(false)}
title={t('chat.messageBody.actions.moreActions')}
>
<div className="flex flex-col">
{timestamp ? (
<div className="mb-1 flex items-center gap-3 border-b border-border/60 px-3 pb-2 text-muted-foreground">
<Icon name="time" className="h-4 w-4" />
<span className="typography-ui-label">{timestamp}</span>
</div>
) : null}
{userSheetActions.map((action) => (
<button
key={action.id}
type="button"
disabled={action.disabled}
className="flex min-h-11 w-full items-center gap-3 rounded-lg px-3 text-left text-foreground transition-colors active:bg-interactive-hover disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
onClick={() => {
setUserActionSheetOpen(false);
action.onSelect();
}}
style={{ touchAction: 'manipulation' }}
>
<span className="text-muted-foreground">{action.icon}</span>
<span className="typography-ui-label">{action.label}</span>
</button>
))}
</div>
</MobileOverlayPanel>
</>
) : (
<>
{onRevert && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('chat.messageBody.actions.forkAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
effectiveOnFork();
}}
>
<Icon name="git-branch" className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.fork')}</TooltipContent>
</Tooltip>
)}
{onToggleContextPin && hasCopyableText && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
contextPinned ? 'text-[color:var(--status-info)]' : 'text-muted-foreground',
)}
disabled={contextPinPending}
aria-pressed={contextPinned}
aria-label={t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); onToggleContextPin(); }}
>
<Icon name={contextPinned ? 'pushpin-2-fill' : 'pushpin-2'} className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}</TooltipContent>
</Tooltip>
)}
{canCopyMessage && hasCopyableText && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('chat.messageBody.actions.copyMessageAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleCopyButtonClick}
onFocus={() => setCopyHintVisible(true)}
onBlur={() => {
if (!isMessageCopied) {
setCopyHintVisible(false);
}
}}
>
{isMessageCopied ? (
<Icon name="check" className="h-3 w-3 text-[color:var(--status-success)]" />
) : (
<Icon name="file-copy" className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.copyMessage')}</TooltipContent>
</Tooltip>
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('chat.messageBody.actions.revertAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onRevert();
}}
>
<Icon name="arrow-go-back" className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.revert')}</TooltipContent>
</Tooltip>
)}
{effectiveOnFork && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('chat.messageBody.actions.forkAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
effectiveOnFork();
}}
>
<Icon name="git-branch" className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.fork')}</TooltipContent>
</Tooltip>
)}
{onToggleContextPin && hasCopyableText && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
contextPinned ? 'text-[color:var(--status-info)]' : 'text-muted-foreground',
)}
disabled={contextPinPending}
aria-pressed={contextPinned}
aria-label={t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); onToggleContextPin(); }}
>
<Icon name={contextPinned ? 'pushpin-2-fill' : 'pushpin-2'} className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}</TooltipContent>
</Tooltip>
)}
{canCopyMessage && hasCopyableText && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('chat.messageBody.actions.copyMessageAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleCopyButtonClick}
onFocus={() => setCopyHintVisible(true)}
onBlur={() => {
if (!isMessageCopied) {
setCopyHintVisible(false);
}
}}
>
{isMessageCopied ? (
<Icon name="check" className="h-3 w-3 text-[color:var(--status-success)]" />
) : (
<Icon name="file-copy" className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.copyMessage')}</TooltipContent>
</Tooltip>
)}
</>
)}
</div>
</div>
) : null;
@@ -981,7 +1083,7 @@ const AssistantMessageActionButtons = React.memo(({
size="icon"
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
className={cn(
'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
'h-7 w-7 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
!hasCopyableText && 'opacity-50'
)}
disabled={!hasCopyableText}
@@ -1021,7 +1123,7 @@ const AssistantMessageActionButtons = React.memo(({
variant="ghost"
disabled={isTransferringReview || !hasCopyableText}
className={cn(
'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
'h-7 w-7 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
(!hasCopyableText || isTransferringReview) && 'opacity-50'
)}
aria-label={reviewTransferAction.ariaLabel}
@@ -1031,9 +1133,9 @@ const AssistantMessageActionButtons = React.memo(({
}}
>
{isTransferringReview ? (
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin" />
) : (
<Icon name="arrow-left-right" className="h-4 w-4" />
<Icon name="arrow-left-right" className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
@@ -1048,7 +1150,7 @@ const AssistantMessageActionButtons = React.memo(({
variant="ghost"
disabled={isSharing || !hasCopyableText}
className={cn(
'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
'h-7 w-7 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
(!hasCopyableText || isSharing) && 'opacity-50'
)}
onPointerDown={(event) => event.stopPropagation()}
@@ -1057,9 +1159,9 @@ const AssistantMessageActionButtons = React.memo(({
}}
>
{isSharing ? (
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin" />
) : (
<Icon name="image-download" className="h-4 w-4" />
<Icon name="image-download" className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
@@ -1073,7 +1175,7 @@ const AssistantMessageActionButtons = React.memo(({
variant="ghost"
size="icon"
className={cn(
'h-8 w-8 bg-transparent hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
'h-7 w-7 bg-transparent hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
isTTSPlaying ? 'text-green-500' : 'text-muted-foreground hover:text-foreground'
)}
aria-label={isTTSPlaying ? t('chat.messageBody.tts.stopSpeaking') : t('chat.messageBody.tts.readAloud')}
@@ -1375,9 +1477,10 @@ const AssistantMessageBody = React.memo(({
const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion;
const handleForkClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
// Optional event: the footer's action sheet calls this without one.
(event?: React.MouseEvent<HTMLButtonElement>) => {
event?.stopPropagation();
event?.preventDefault();
if (!assistantPlanText.trim()) {
return;
}
@@ -1438,9 +1541,10 @@ const AssistantMessageBody = React.memo(({
);
const handleSaveAsPlanClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
// Optional event: the footer's action sheet calls this without one.
(event?: React.MouseEvent<HTMLButtonElement>) => {
event?.stopPropagation();
event?.preventDefault();
if (!assistantPlanText.trim()) {
return;
}
@@ -2059,9 +2163,98 @@ const AssistantMessageBody = React.memo(({
return formatted.length > 0 ? formatted : null;
}, [messageCompletedAt, messageCreatedAt, timeFormatPreference, locale]);
const footerTimestampClassName = 'text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1';
const footerTimestampClassName = 'text-sm text-muted-foreground/60 tabular-nums';
// Touch surfaces have no hover, so the footer would have to show every
// action at all times — four 36px targets that pushed the metadata onto its
// own lines. Collapse them into one "more" button and a labelled sheet, the
// same one the composer uses to pick a model. The buttons below stay the
// pointer path; these rows call the same handlers, minus the transient
// copied/sharing states that only make sense on a button that stays put.
const [actionSheetOpen, setActionSheetOpen] = React.useState(false);
const { isPlaying: isFooterTTSPlaying, play: playFooterTTS, stop: stopFooterTTS } = useMessageTTS();
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
const canOpenMessagePreview = !isMiniChatSurface && !isMobile && !isVSCode;
const footerSheetActions = React.useMemo(() => {
const actions: Array<{ id: string; label: string; icon: React.ReactNode; disabled?: boolean; onSelect: () => void }> = [];
if (onCopyMessage) {
actions.push({
id: 'copy',
label: t('chat.messageBody.actions.copyAnswer'),
icon: <Icon name="file-copy" className="h-4 w-4" />,
disabled: !hasCopyableText,
// The sheet closes on tap, so the button's own "copied" tick has
// nowhere to land — say it with a toast instead.
onSelect: () => {
void (async () => {
const copied = await onCopyMessage();
if (copied !== false) toast.success(t('chat.messageBody.toast.copied'));
})();
},
});
}
if (reviewTransferAction && !isMiniChatSurface) {
actions.push({
id: 'review-transfer',
label: reviewTransferAction.tooltip,
icon: <Icon name="arrow-left-right" className="h-3.5 w-3.5" />,
disabled: !hasCopyableText,
onSelect: () => { void reviewTransferAction.onClick(); },
});
}
if (!isMiniChatSurface) {
actions.push({
id: 'share-image',
label: t('chat.messageBody.actions.saveAsImage'),
icon: <Icon name="image-download" className="h-3.5 w-3.5" />,
disabled: !hasCopyableText,
onSelect: () => { void shareMessageAsImage(); },
});
}
if (!isMiniChatSurface && showMessageTTSButtons && hasCopyableText) {
actions.push({
id: 'tts',
label: isFooterTTSPlaying ? t('chat.messageBody.tts.stopSpeaking') : t('chat.messageBody.tts.readAloud'),
icon: <Icon name={isFooterTTSPlaying ? 'stop' : 'volume-up'} className="h-4 w-4" />,
onSelect: () => {
if (isFooterTTSPlaying) {
stopFooterTTS();
return;
}
if (assistantPlanText.trim()) void playFooterTTS(assistantPlanText);
},
});
}
if (canUseProjectPlanActions && !isReviewSessionView) {
actions.push({
id: 'save-as-plan',
label: t('chat.messageBody.actions.saveAsPlan'),
icon: <Icon name="booklet" className="h-3.5 w-3.5" />,
disabled: !hasCopyableText || !currentProjectRef,
onSelect: () => { handleSaveAsPlanClick(); },
});
}
if (onToggleContextPin && hasCopyableText) {
actions.push({
id: 'pin-context',
label: t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext'),
icon: <Icon name={contextPinned ? 'pushpin-2-fill' : 'pushpin-2'} className="h-4 w-4" />,
disabled: contextPinPending,
onSelect: () => { onToggleContextPin(); },
});
}
if (!isMiniChatSurface && !isReviewSessionView) {
actions.push({
id: 'fork',
label: t('chat.messageBody.actions.startNewSession'),
icon: <Icon name="chat-new" className="h-3.5 w-3.5" />,
onSelect: () => { handleForkClick(); },
});
}
return actions;
}, [assistantPlanText, canUseProjectPlanActions, contextPinPending, contextPinned, currentProjectRef, handleForkClick, handleSaveAsPlanClick, hasCopyableText, isFooterTTSPlaying, isMiniChatSurface, isReviewSessionView, onCopyMessage, onToggleContextPin, playFooterTTS, reviewTransferAction, shareMessageAsImage, showMessageTTSButtons, stopFooterTTS, t]);
const finalTurnActionButtons = (
<>
{canOpenMessagePreview && messagePreviewUrl ? (
@@ -2071,7 +2264,7 @@ const AssistantMessageBody = React.memo(({
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
className="h-7 w-7 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('chat.messageBody.actions.openPreviewAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={() => {
@@ -2083,7 +2276,7 @@ const AssistantMessageBody = React.memo(({
openContextPreview(directory, messagePreviewUrl);
}}
>
<Icon name="global" className="h-4 w-4" />
<Icon name="global" className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.openPreview')}</TooltipContent>
@@ -2098,13 +2291,13 @@ const AssistantMessageBody = React.memo(({
variant="ghost"
disabled={!hasCopyableText || !currentProjectRef}
className={cn(
'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
'h-7 w-7 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
(!hasCopyableText || !currentProjectRef) && 'opacity-50'
)}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleSaveAsPlanClick}
>
<Icon name="booklet" className="h-4 w-4" />
<Icon name="booklet" className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.saveAsPlan')}</TooltipContent>
@@ -2118,7 +2311,7 @@ const AssistantMessageBody = React.memo(({
variant="ghost"
size="icon"
className={cn(
'h-8 w-8 bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
'h-7 w-7 bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
contextPinned ? 'text-[color:var(--status-info)]' : 'text-muted-foreground',
)}
disabled={contextPinPending}
@@ -2139,11 +2332,11 @@ const AssistantMessageBody = React.memo(({
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
className="h-7 w-7 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleForkClick}
>
<Icon name="chat-new" className="h-4 w-4" />
<Icon name="chat-new" className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewSession')}</TooltipContent>
@@ -2155,11 +2348,11 @@ const AssistantMessageBody = React.memo(({
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
className="h-7 w-7 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleForkMultiRunClick}
>
<ArrowsMerge className="h-4 w-4" />
<ArrowsMerge className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewMultiRun')}</TooltipContent>
@@ -2239,93 +2432,146 @@ const AssistantMessageBody = React.memo(({
)}
{shouldShowTurnFooter && (
<div
className="mt-2 mb-1 flex flex-wrap items-center justify-start gap-x-3 gap-y-1.5"
className="mt-2 mb-1 flex flex-col gap-y-1.5"
style={MESSAGE_FOOTER_CONTAINER_STYLE}
>
<div className="flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-1 text-sm text-muted-foreground/60">
{footerModelName ? (
<span className="flex min-w-0 items-center gap-1.5">
{footerHasLogo && footerLogoSrc ? (
<img
src={footerLogoSrc}
alt=""
className="h-3.5 w-3.5 flex-shrink-0"
style={{
filter: isDarkTheme ? 'brightness(0.9) contrast(1.1) invert(1)' : 'brightness(0.9) contrast(1.1)',
}}
onError={handleFooterLogoError}
/>
) : (
<Icon
name="brain-ai-3"
className="h-3.5 w-3.5 flex-shrink-0"
style={{ color: `var(${getAgentColor(footerAgentName).var})` }}
/>
)}
<span className="truncate">{footerModelName}</span>
</span>
) : null}
{footerVariant && !['default', 'none'].includes(footerVariant.toLowerCase()) ? (
<span className="flex items-center gap-1">
<Icon name="brain-ai-3" className="h-3.5 w-3.5 flex-shrink-0" />
<span className="message-footer__label">
{footerVariant[0].toLowerCase() + footerVariant.slice(1)}
</span>
</span>
) : null}
{footerAgentName ? (
<span className="flex items-center gap-1">
<Icon name="ai-agent" className="h-3.5 w-3.5 flex-shrink-0" />
<span className="message-footer__label">{footerAgentName}</span>
</span>
) : null}
{turnDurationText ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
<Icon name="hourglass" className="h-3.5 w-3.5" />
<span className="message-footer__label">{turnDurationText}</span>
<div className="flex items-center justify-between gap-2">
{/* One line, always. The facts are ordered by how much they
matter, and the CSS drops them from the tail as the row
narrows: first the time, then the agent, then the thinking
effort. Model and duration never leave — the model only
truncates once those two alone stop fitting. */}
<div className="message-footer__facts flex-1 whitespace-nowrap text-sm text-muted-foreground/60">
<span className="message-footer__facts-core">
{footerModelName ? (
<span className="flex min-w-0 items-center gap-1.5">
{footerHasLogo && footerLogoSrc ? (
<img
src={footerLogoSrc}
alt=""
className="h-3.5 w-3.5 flex-shrink-0"
style={{
filter: isDarkTheme ? 'brightness(0.9) contrast(1.1) invert(1)' : 'brightness(0.9) contrast(1.1)',
}}
onError={handleFooterLogoError}
/>
) : (
<Icon
name="brain-ai-3"
className="h-3.5 w-3.5 flex-shrink-0"
style={{ color: `var(${getAgentColor(footerAgentName).var})` }}
/>
)}
<span className="truncate">{footerModelName}</span>
</span>
</TooltipTrigger>
<TooltipContent>{turnDurationText}</TooltipContent>
</Tooltip>
) : null}
{footerTimestamp ? (
<Tooltip>
<TooltipTrigger asChild>
) : null}
</span>
{/* Thinking effort and agent drop out from the tail — the
agent first — when the row runs short. */}
<span className="message-footer__facts-optional">
{footerVariant && !['default', 'none'].includes(footerVariant.toLowerCase()) ? (
<span className="message-footer__fact">
<span className="opacity-60" aria-hidden>·</span>
{footerVariant[0].toLowerCase() + footerVariant.slice(1)}
</span>
) : null}
{footerAgentName ? (
<span className="message-footer__fact">
<span className="opacity-60" aria-hidden>·</span>
{footerAgentName}
</span>
) : null}
</span>
{turnDurationText ? (
<span className="message-footer__facts-duration message-footer__fact tabular-nums">
{footerModelName ? <span className="opacity-60" aria-hidden>·</span> : null}
{turnDurationText}
</span>
) : null}
{/* Pointer surfaces keep the timestamp inline (it goes first
when space runs out); touch reads it in the actions
sheet, where nothing can push it off the row. */}
{footerTimestamp && !(alwaysShowMessageActions || isTouchContext) ? (
<span className="message-footer__facts-optional message-footer__facts-optional--drops-first">
<span
className={footerTimestampClassName}
className={cn(footerTimestampClassName, 'message-footer__fact')}
aria-label={`Message time: ${footerTimestamp}`}
>
<Icon name="time" className="h-3.5 w-3.5" />
<span className="message-footer__label">{footerTimestamp}</span>
<span className="opacity-60" aria-hidden>·</span>
{footerTimestamp}
</span>
</TooltipTrigger>
<TooltipContent>{footerTimestamp}</TooltipContent>
</Tooltip>
) : null}
{!isMiniChatSurface && isLastAssistantInTurn && hasStopFinish ? (
<TurnChangedFilesDropdown activityParts={turnGroupingContext?.activityParts} />
) : null}
{!isMiniChatSurface && isLastAssistantInTurn && hasStopFinish ? (
<TurnChangedFilePills
files={turnGroupingContext?.changedFiles}
isInteractive={turnGroupingContext?.isLatestTurn === true}
/>
) : null}
</span>
) : null}
</div>
<div
className={cn(
'flex items-center gap-1.5',
alwaysShowMessageActions || isTouchContext
? undefined
: 'pointer-events-none opacity-0 transition-opacity duration-150 focus-within:pointer-events-auto focus-within:opacity-100 group-hover/message:pointer-events-auto group-hover/message:opacity-100'
)}
data-message-action-group="true"
{alwaysShowMessageActions || isTouchContext ? (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('chat.messageBody.actions.moreActions')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
setActionSheetOpen(true);
}}
data-message-action-group="true"
>
<Icon name="more" className="h-3.5 w-3.5" />
</Button>
) : (
<div
className="flex shrink-0 items-center gap-1.5 pointer-events-none opacity-0 transition-opacity duration-150 focus-within:pointer-events-auto focus-within:opacity-100 group-hover/message:pointer-events-auto group-hover/message:opacity-100"
data-message-action-group="true"
>
{messageActionButtons}
{finalTurnActionButtons}
</div>
)}
</div>
{/* Changed files keep their own line: they are a list that
grows, not a fact about the run. */}
{!isMiniChatSurface && isLastAssistantInTurn && hasStopFinish ? (
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<TurnChangedFilesDropdown activityParts={turnGroupingContext?.activityParts} />
<TurnChangedFilePills
files={turnGroupingContext?.changedFiles}
isInteractive={turnGroupingContext?.isLatestTurn === true}
/>
</div>
) : null}
<MobileOverlayPanel
open={actionSheetOpen}
onClose={() => setActionSheetOpen(false)}
title={t('chat.messageBody.actions.moreActions')}
>
{messageActionButtons}
{finalTurnActionButtons}
</div>
<div className="flex flex-col">
{/* The row drops the timestamp first on a narrow screen,
so the sheet is where it is always readable. */}
{footerTimestamp ? (
<div className="mb-1 flex items-center gap-3 border-b border-border/60 px-3 pb-2 text-muted-foreground">
<Icon name="time" className="h-4 w-4" />
<span className="typography-ui-label">{footerTimestamp}</span>
</div>
) : null}
{footerSheetActions.map((action) => (
<button
key={action.id}
type="button"
disabled={action.disabled}
className="flex min-h-11 w-full items-center gap-3 rounded-lg px-3 text-left text-foreground transition-colors active:bg-interactive-hover disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
onClick={() => {
setActionSheetOpen(false);
action.onSelect();
}}
style={{ touchAction: 'manipulation' }}
>
<span className="text-muted-foreground">{action.icon}</span>
<span className="typography-ui-label">{action.label}</span>
</button>
))}
</div>
</MobileOverlayPanel>
</div>
)}
+203 -139
View File
@@ -1,17 +1,21 @@
import React from 'react';
import { useUIStore } from '@/stores/useUIStore';
import { useUIStore, type PendingDiffScope } from '@/stores/useUIStore';
import { useCommitComparison } from '@/hooks/useCommitComparison';
import { CommitComparisonSelector } from '@/components/views/git/CommitComparisonSelector';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
import { NestedRepoPicker } from '@/components/views/git/NestedRepoPicker';
import { BranchComparisonSelector } from '@/components/views/git/BranchComparisonSelector';
import { branchRefLabel } from '@/components/views/git/baseBranch';
import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore';
import { useGitBaseBranchStore, gitBaseBranchEntryKey } from '@/stores/useGitBaseBranchStore';
import { useGitBaseBranchStore } from '@/stores/useGitBaseBranchStore';
import { useBranchComparisonBase } from '@/hooks/useBranchComparisonBase';
import { coerceDiffScope, branchRangeKey, isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache, useBoundedDirectoryRetry } from './branchDiffScope';
import { getBranchBase, getGitRangeDiff, getGitRangeFiles } from '@/lib/gitApi';
import { getGitRangeDiff, getGitRangeFiles, getCommitFiles, getGitCommitDiff } from '@/lib/gitApi';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { cn } from '@/lib/utils';
import { rankByQuery } from '@/lib/search/fuzzySearch';
import type { GitStatus, GitRangeFileEntry } from '@/lib/api/types';
import type { GitStatus, GitRangeFileEntry, CommitFileEntry } from '@/lib/api/types';
import {
DropdownMenu,
DropdownMenuContent,
@@ -85,7 +89,7 @@ type DiffData = {
fileDiff?: FileDiffMetadata;
contextMode?: DiffContextMode;
};
type DiffScope = 'all' | 'staged' | 'working' | 'turn' | 'branch';
type DiffScope = 'all' | PendingDiffScope;
type TurnSnapshotDiff = {
file?: string;
@@ -97,17 +101,16 @@ type TurnSnapshotDiff = {
deletions?: number;
};
/** Reservation slot for a branch range diff while its fetch is in flight. */
const EMPTY_BRANCH_DIFF_PLACEHOLDER: DiffData = {
original: '',
modified: '',
isBinary: false,
contextMode: 'patch',
};
type ComparisonDiffResult =
| { status: 'loading' }
| { status: 'ready'; data: DiffData }
| { status: 'error'; message: string };
const EMPTY_COMPARISON_DIFF: ComparisonDiffResult = { status: 'loading' };
/** Bounded retries for branch metadata in the context diff panel (see effect). */
const BRANCH_METADATA_MAX_ATTEMPTS = 3;
const BinaryDiffPlaceholder = React.memo(() => {
const { t } = useI18n();
return (
@@ -247,13 +250,15 @@ const formatDiffTotals = (
};
interface ChangeScopeSelectorProps {
scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>;
scope: PendingDiffScope;
workingCount: number;
stagedCount: number;
turnCount: number;
branchCount: number | null;
commitCount: number | null;
showCommitOption: boolean;
showBranchOption: boolean;
onScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>) => void;
onScopeChange?: (scope: PendingDiffScope) => void;
}
const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
@@ -262,19 +267,21 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
stagedCount,
turnCount,
branchCount,
commitCount,
showCommitOption,
showBranchOption,
onScopeChange,
}) => {
const { t } = useI18n();
const [open, setOpen] = React.useState(false);
const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : scope === 'branch' ? (branchCount ?? 0) : workingCount;
const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : scope === 'branch' ? (branchCount ?? 0) : scope === 'commit' ? (commitCount ?? 0) : workingCount;
const currentLabel = scope === 'staged'
? t('diffView.scope.staged')
: scope === 'turn'
? t('diffView.scope.lastTurn')
: scope === 'branch'
? t('diffView.scope.branch')
: t('diffView.scope.changed');
: scope === 'commit' ? t('commitComparison.mode') : t('diffView.scope.changed');
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
@@ -294,7 +301,7 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
<DropdownMenuRadioGroup
value={scope}
onValueChange={(value) => {
if (value === 'working' || value === 'staged' || value === 'turn' || value === 'branch') {
if (value === 'working' || value === 'staged' || value === 'turn' || value === 'branch' || value === 'commit') {
onScopeChange?.(value);
setOpen(false);
}
@@ -326,6 +333,14 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
</span>
</DropdownMenuRadioItem>
) : null}
{showCommitOption && (
<DropdownMenuRadioItem value="commit">
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span>{t('commitComparison.mode')}</span>
<span className="typography-meta text-muted-foreground">{commitCount ?? '…'}</span>
</span>
</DropdownMenuRadioItem>
)}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
@@ -605,7 +620,9 @@ interface MultiFileDiffEntryProps {
staged?: boolean;
loadFullFiles?: boolean;
initialDiffData?: DiffData | null;
/** Hide stage/unstage/revert actions (read-only scopes like branch diffs). */
comparisonDiff?: ComparisonDiffResult;
onRetryComparisonDiff?: () => void;
/** Hide stage/unstage/revert actions for branch and commit comparisons. */
readOnlyActions?: boolean;
}
@@ -626,6 +643,8 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
staged = false,
loadFullFiles = false,
initialDiffData = null,
comparisonDiff,
onRetryComparisonDiff,
readOnlyActions = false,
}) => {
const { t } = useI18n();
@@ -640,8 +659,10 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
const setDiffFileLayout = useUIStore((state) => state.setDiffFileLayout);
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [localDiffLoadError, setDiffLoadError] = React.useState<string | null>(null);
const [isFetching, setIsLoading] = React.useState(false);
const diffLoadError = comparisonDiff ? (comparisonDiff.status === 'error' ? comparisonDiff.message : null) : localDiffLoadError;
const isLoading = comparisonDiff ? comparisonDiff.status === 'loading' : isFetching;
const [fileAction, setFileAction] = React.useState<FileDiffAction | null>(null);
const [forceRenderLarge, setForceRenderLarge] = React.useState(false);
const [localDiffData, setLocalDiffData] = React.useState<DiffData | null>(null);
@@ -655,12 +676,13 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
const fileStatusKey = `${file.index}:${file.working_dir}:${file.insertions}:${file.deletions}`;
const diffData = React.useMemo<DiffData | null>(() => {
if (comparisonDiff) return comparisonDiff.status === 'ready' ? comparisonDiff.data : null;
if (initialDiffData) return initialDiffData;
if (staged) return stagedDiffData;
if (localDiffData) return localDiffData;
if (!cachedDiff) return null;
return { original: cachedDiff.original, modified: cachedDiff.modified, isBinary: cachedDiff.isBinary, contextMode: 'full' };
}, [cachedDiff, initialDiffData, localDiffData, staged, stagedDiffData]);
}, [comparisonDiff, cachedDiff, initialDiffData, localDiffData, staged, stagedDiffData]);
const diffDataMatchesContextMode = diffData?.contextMode === desiredContextMode;
@@ -690,7 +712,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
React.useEffect(() => {
if (!isExpanded || !isMounted) return;
if (!directory || initialDiffData || (diffData && diffDataMatchesContextMode)) {
if (!directory || comparisonDiff || initialDiffData || (diffData && diffDataMatchesContextMode)) {
lastDiffRequestRef.current = null;
setIsLoading(false);
return;
@@ -754,7 +776,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
lastDiffRequestRef.current = null;
}
};
}, [desiredContextMode, diffData, diffDataMatchesContextMode, diffRetryNonce, directory, file.path, fileStatusKey, git, initialDiffData, isExpanded, isMounted, loadFullFiles, setDiff, staged]);
}, [comparisonDiff, desiredContextMode, diffData, diffDataMatchesContextMode, diffRetryNonce, directory, file.path, fileStatusKey, git, initialDiffData, isExpanded, isMounted, loadFullFiles, setDiff, staged]);
const handleToggle = React.useCallback(() => {
handleOpenChange(!isExpanded);
@@ -917,7 +939,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
<button
type="button"
className="typography-ui-label text-primary hover:underline"
onClick={() => setDiffRetryNonce((nonce) => nonce + 1)}
onClick={() => comparisonDiff ? onRetryComparisonDiff?.() : setDiffRetryNonce((nonce) => nonce + 1)}
>
{t('diffView.actions.retry')}
</button>
@@ -981,7 +1003,7 @@ interface DiffViewProps {
pinSelectedFileHeaderToTopOnNavigate?: boolean;
showOpenInEditorAction?: boolean;
diffScope?: DiffScope;
onDiffScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>) => void;
onDiffScopeChange?: (scope: PendingDiffScope) => void;
targetFilePath?: string | null;
/** Render diff content flush with the container edges (no outer padding). */
flushContent?: boolean;
@@ -1051,7 +1073,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
const activeDiffStaged = forcedStaged ?? displayFileStaged;
const isMobileLayout = isMobile || screenWidth <= 768;
const showReviewAction = Boolean(currentSessionId) && activeDiffScope !== 'turn' && !isMobileLayout && !isVSCodeRuntime();
const showReviewAction = Boolean(currentSessionId) && activeDiffScope !== 'turn' && activeDiffScope !== 'commit' && !isMobileLayout && !isVSCodeRuntime();
// Same runtime and width rules as the rail surface: no point offering an
// entry point to a surface that cannot open here.
const showWalkthroughAction = activeDiffScope !== 'turn' && !isMobileLayout && !isVSCodeRuntime();
@@ -1139,6 +1161,37 @@ export const DiffView: React.FC<DiffViewProps> = ({
// ----- Branch scope (all changes on this branch vs its base) -----
const currentBranch = status?.current ?? null;
const commitComparison = useCommitComparison(effectiveDirectory ?? null, currentBranch, activeDiffScope === 'commit' && !isVSCodeRuntime());
const selectedCommitHash = commitComparison.selectedCommit?.hash ?? null;
const commitQueryKey = activeDiffScope === 'commit' && effectiveDirectory && selectedCommitHash
? JSON.stringify([getRuntimeKey(), effectiveDirectory, selectedCommitHash])
: null;
const [commitFilesResult, setCommitFilesResult] = React.useState<
{ key: string; files: CommitFileEntry[] } | { key: string; error: string } | null
>(null);
const [commitFilesRetry, setCommitFilesRetry] = React.useState(0);
const currentCommitFiles = commitFilesResult?.key === commitQueryKey ? commitFilesResult : null;
const commitFiles = currentCommitFiles && 'files' in currentCommitFiles ? currentCommitFiles.files : null;
const commitFilesError = currentCommitFiles && 'error' in currentCommitFiles ? currentCommitFiles.error : null;
const commitFilesByPath = React.useMemo(() => new Map((commitFiles ?? []).map((file) => [file.path, file])), [commitFiles]);
React.useEffect(() => {
if (!commitQueryKey || !effectiveDirectory || !selectedCommitHash) return;
let cancelled = false;
setCommitFilesResult(null);
getCommitFiles(effectiveDirectory, selectedCommitHash)
.then(({ files }) => { if (!cancelled) setCommitFilesResult({ key: commitQueryKey, files }); })
.catch((error) => {
if (!cancelled) setCommitFilesResult({ key: commitQueryKey, error: error instanceof Error ? error.message : t('diffView.state.failedToLoadDiff') });
});
return () => { cancelled = true; };
}, [commitFilesRetry, commitQueryKey, effectiveDirectory, selectedCommitHash, t]);
React.useEffect(() => {
if (activeDiffScope === 'commit' && isVSCodeRuntime()) {
setActiveDiffScope('working');
onDiffScopeChange?.('working');
}
}, [activeDiffScope, onDiffScopeChange]);
const branches = useGitStore((state) => (effectiveDirectory ? state.directories.get(effectiveDirectory)?.branches ?? null : null));
const isLoadingBranches = useGitStore((state) => (effectiveDirectory ? state.directories.get(effectiveDirectory)?.isLoadingBranches ?? false : false));
@@ -1192,22 +1245,12 @@ export const DiffView: React.FC<DiffViewProps> = ({
);
const setBaseOverride = useGitBaseBranchStore((state) => state.setOverride);
// Subscribe to the overrides map directly: `getOverride` reads `get()`
// imperatively, so a memo over it never recomputes when the store changes
// and a freshly picked base would be invisible until an unrelated rerender.
// The key includes the current branch: a base picked for one feature branch
// is not an answer for another branch of the same repository.
const baseOverride = useGitBaseBranchStore(
React.useCallback(
(state) => (effectiveDirectory && currentBranch
? state.overrides[gitBaseBranchEntryKey(effectiveDirectory, currentBranch)] ?? null
: null),
[currentBranch, effectiveDirectory]
)
const { base: branchBase, resolved: isBranchBaseResolved, revision: branchRevision } = useBranchComparisonBase(
effectiveDirectory ?? null,
currentBranch,
showBranchOption && activeDiffScope === 'branch',
);
const [detectedBranchBase, setDetectedBranchBase] = React.useState<string | null>(null);
const [isBranchBaseResolved, setIsBranchBaseResolved] = React.useState(false);
const [basePickerSearch, setBasePickerSearch] = React.useState('');
const [comparisonRetryRevision, setComparisonRetryRevision] = React.useState(0);
// A context tab persists its scope across branch checkouts and runtime
// switches. When the Branch scope is CONFIRMED unavailable (checked out the
@@ -1228,97 +1271,100 @@ export const DiffView: React.FC<DiffViewProps> = ({
}
}, [activeDiffScope, branchScopeDefinitelyUnavailable, onDiffScopeChange]);
React.useEffect(() => {
if (!showBranchOption || !effectiveDirectory || !currentBranch) {
setDetectedBranchBase(null);
setIsBranchBaseResolved(false);
return;
}
let cancelled = false;
setIsBranchBaseResolved(false);
getBranchBase(effectiveDirectory, currentBranch)
.then((result) => {
if (!cancelled) setDetectedBranchBase(result.base);
})
.catch(() => {
if (!cancelled) setDetectedBranchBase(null);
})
.finally(() => {
if (!cancelled) setIsBranchBaseResolved(true);
});
return () => {
cancelled = true;
};
}, [currentBranch, effectiveDirectory, showBranchOption]);
// Explicit user choice outranks the detected source; both are real answers
// from git or the user — never a main/master guess.
const branchBase = baseOverride ?? detectedBranchBase;
const [branchFiles, setBranchFiles] = React.useState<GitRangeFileEntry[] | null>(null);
const [branchFilesError, setBranchFilesError] = React.useState<string | null>(null);
const branchQueryKey = effectiveDirectory && currentBranch && branchBase
? JSON.stringify([getRuntimeKey(), effectiveDirectory, branchBase, currentBranch])
: null;
const [branchFilesResult, setBranchFilesResult] = React.useState<
{ key: string; files: GitRangeFileEntry[] } | { key: string; error: string } | null
>(null);
const currentBranchFilesResult = branchFilesResult?.key === branchQueryKey ? branchFilesResult : null;
const branchFiles = currentBranchFilesResult && 'files' in currentBranchFilesResult ? currentBranchFilesResult.files : null;
const branchFilesError = currentBranchFilesResult && 'error' in currentBranchFilesResult ? currentBranchFilesResult.error : null;
// Shared by the scope/base effect and the error-state Retry button; the
// fetch id discards completions from a superseded run (base or head
// changed, or an earlier retry is still in flight).
const branchFilesFetchIdRef = React.useRef(0);
const reloadBranchFiles = React.useCallback(() => {
if (!effectiveDirectory || !currentBranch || !branchBase) return;
if (!effectiveDirectory || !currentBranch || !branchBase || !branchQueryKey) return;
const fetchId = branchFilesFetchIdRef.current + 1;
branchFilesFetchIdRef.current = fetchId;
setBranchFiles(null);
setBranchFilesError(null);
getGitRangeFiles(effectiveDirectory, { base: branchBase, head: currentBranch })
setBranchFilesResult((previous) => previous?.key === branchQueryKey && 'files' in previous ? previous : null);
getGitRangeFiles(effectiveDirectory, { base: branchBase, head: currentBranch, includeWorkingTree: true })
.then((files) => {
if (branchFilesFetchIdRef.current === fetchId) setBranchFiles(files);
if (branchFilesFetchIdRef.current === fetchId) setBranchFilesResult({ key: branchQueryKey, files });
})
.catch((error) => {
if (branchFilesFetchIdRef.current === fetchId) {
setBranchFilesError(error instanceof Error ? error.message : t('diffView.branch.loadError'));
setBranchFilesResult({ key: branchQueryKey, error: error instanceof Error ? error.message : t('diffView.branch.loadError') });
}
});
}, [branchBase, currentBranch, effectiveDirectory, t]);
}, [branchBase, branchQueryKey, currentBranch, effectiveDirectory, t]);
React.useEffect(() => {
if (activeDiffScope === 'branch') {
reloadBranchFiles();
}
}, [activeDiffScope, reloadBranchFiles]);
return () => { branchFilesFetchIdRef.current += 1; };
}, [activeDiffScope, branchRevision, reloadBranchFiles]);
// Range diffs are fetched per expanded file: unlike working/staged diffs
// there is no per-file cache channel, so patch data lives in a range-keyed
// local cache. Stale completions from a previous range cannot write into
// the new range's cache (see useRangeKeyedCache).
const branchDiffRangeKey = activeDiffScope === 'branch' && effectiveDirectory && currentBranch && branchBase
? branchRangeKey(effectiveDirectory, branchBase, currentBranch)
const comparisonRangeKey = activeDiffScope === 'commit' ? (commitFiles ? commitQueryKey : null) : activeDiffScope === 'branch' && effectiveDirectory && currentBranch && branchBase
? branchRangeKey(effectiveDirectory, branchBase, currentBranch) + getRuntimeKey()
: null;
const branchDiffPathsKey = React.useMemo(
() => (activeDiffScope === 'branch' ? Array.from(expandedFiles).sort().join('\0') : ''),
const comparisonPathsKey = React.useMemo(
() => (activeDiffScope === 'branch' || activeDiffScope === 'commit' ? Array.from(expandedFiles).sort().join('\0') : ''),
[activeDiffScope, expandedFiles]
);
const fetchBranchDiffEntry = React.useCallback(
(filePath: string) => {
if (!effectiveDirectory || !branchBase || !currentBranch) {
return Promise.reject(new Error('branch range is unavailable'));
const fetchComparisonDiffEntry = React.useCallback(
async (filePath: string): Promise<ComparisonDiffResult> => {
if (!effectiveDirectory) return EMPTY_COMPARISON_DIFF;
try {
if (activeDiffScope === 'commit' && selectedCommitHash) {
const response = await getGitCommitDiff(effectiveDirectory, {
hash: selectedCommitHash, path: filePath,
previousPath: commitFilesByPath.get(filePath)?.previousPath,
contextLines: loadFullFiles ? FULL_CONTEXT_DIFF_LINES : DEFAULT_CONTEXT_DIFF_LINES,
});
return { status: 'ready', data: createTextDiffDataFromPatch(filePath, response.diff, loadFullFiles ? 'full' : 'patch') };
}
if (!branchBase || !currentBranch) return EMPTY_COMPARISON_DIFF;
const response = await getGitRangeDiff(effectiveDirectory, {
base: branchBase,
head: currentBranch,
path: filePath,
includeWorkingTree: true,
contextLines: loadFullFiles ? FULL_CONTEXT_DIFF_LINES : DEFAULT_CONTEXT_DIFF_LINES,
});
return { status: 'ready', data: createTextDiffDataFromPatch(filePath, response.diff, loadFullFiles ? 'full' : 'patch') };
} catch (error) {
return { status: 'error', message: error instanceof Error ? error.message : t('diffView.state.failedToLoadDiff') };
}
return getGitRangeDiff(effectiveDirectory, { base: branchBase, head: currentBranch, path: filePath })
.then((response) => createTextDiffDataFromPatch(filePath, response.diff, 'patch'));
},
[branchBase, currentBranch, effectiveDirectory]
[activeDiffScope, branchBase, commitFilesByPath, currentBranch, effectiveDirectory, loadFullFiles, selectedCommitHash, t]
);
const branchDiffData = useRangeKeyedCache<DiffData>(
branchDiffRangeKey,
branchDiffPathsKey,
branchDiffRangeKey ? fetchBranchDiffEntry : null,
EMPTY_BRANCH_DIFF_PLACEHOLDER
const comparisonDiffData = useRangeKeyedCache<ComparisonDiffResult>(
comparisonRangeKey,
comparisonPathsKey,
comparisonRangeKey ? fetchComparisonDiffEntry : null,
EMPTY_COMPARISON_DIFF,
JSON.stringify([activeDiffScope === 'branch' ? branchRevision : '', comparisonRetryRevision, loadFullFiles])
);
const branchFileCount = branchFiles?.length ?? null;
const changedFiles: FileEntry[] = React.useMemo(() => {
if (activeDiffScope === 'commit') {
return (commitFiles ?? []).map((file) => ({
path: file.path, index: '', working_dir: file.changeType,
insertions: file.insertions, deletions: file.deletions, isNew: file.changeType === 'A',
}));
}
if (activeDiffScope === 'branch') {
return (branchFiles ?? [])
.map((file) => ({
@@ -1363,7 +1409,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
isNew: isNewStatusFile(file),
}))
.sort((a, b) => a.path.localeCompare(b.path));
}, [activeDiffScope, branchFiles, lastTurnDiffs, status]);
}, [activeDiffScope, branchFiles, commitFiles, lastTurnDiffs, status]);
const changedFilePathsKey = React.useMemo(
() => changedFiles.map((file) => file.path).join('\0'),
@@ -1937,13 +1983,15 @@ export const DiffView: React.FC<DiffViewProps> = ({
}}
staged={getFileStaged(file.path)}
loadFullFiles={loadFullFiles}
readOnlyActions={activeDiffScope === 'branch'}
readOnlyActions={activeDiffScope === 'branch' || activeDiffScope === 'commit'}
comparisonDiff={activeDiffScope === 'branch' || activeDiffScope === 'commit'
? comparisonDiffData.get(file.path) ?? EMPTY_COMPARISON_DIFF
: undefined}
onRetryComparisonDiff={() => setComparisonRetryRevision((revision) => revision + 1)}
initialDiffData={
activeDiffScope === 'turn'
? lastTurnDiffData.get(file.path) ?? null
: activeDiffScope === 'branch'
? branchDiffData.get(file.path) ?? null
: null
: null
}
/>
))}
@@ -1981,6 +2029,25 @@ export const DiffView: React.FC<DiffViewProps> = ({
);
}
if (activeDiffScope === 'commit') {
if (commitFilesError) {
return <div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center">
<p className="typography-meta text-muted-foreground">{commitFilesError}</p>
<Button variant="outline" size="sm" onClick={() => setCommitFilesRetry((value) => value + 1)}>{t('diffView.actions.retry')}</Button>
</div>;
}
if (!selectedCommitHash && !commitComparison.loading) {
return <div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{commitComparison.error ?? t('commitComparison.noCommits')}
</div>;
}
if (!commitFiles) {
return <div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />{t('diffView.state.loadingDiff')}
</div>;
}
}
if (activeDiffScope === 'branch') {
if (!isBranchBaseResolved) {
return (
@@ -1992,45 +2059,11 @@ export const DiffView: React.FC<DiffViewProps> = ({
}
if (!branchBase) {
const eligibleBranches = (branches?.all ?? [])
.map((name: string) => name.replace(/^remotes\//, ''))
.filter((name: string) => name !== currentBranch && !name.endsWith(`/${currentBranch}`))
.sort();
const candidateBranches = rankByQuery(eligibleBranches, basePickerSearch, (name) => [name]);
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<Icon name="git-branch" className="size-6 text-muted-foreground" />
<div className="typography-ui-label font-semibold text-foreground">{t('diffView.branch.noBaseTitle')}</div>
<div className="max-w-sm typography-micro text-muted-foreground">{t('diffView.branch.noBaseDescription')}</div>
<input
type="text"
value={basePickerSearch}
onChange={(event) => setBasePickerSearch(event.target.value)}
placeholder={t('gitView.branch.searchPlaceholder')}
aria-label={t('gitView.branch.searchPlaceholder')}
className="w-full max-w-sm rounded-md border border-border/60 bg-[var(--surface-elevated)] px-2.5 py-1.5 typography-meta text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
/>
<ScrollableOverlay outerClassName="max-h-48 w-full max-w-sm min-h-0" className="px-1 py-1">
{candidateBranches.length === 0 ? (
<div className="px-2 py-3 typography-meta text-muted-foreground">
{t('gitView.branch.empty')}
</div>
) : (
<div className="flex flex-col gap-0.5">
{candidateBranches.map((branch: string) => (
<button
key={branch}
type="button"
onClick={() => effectiveDirectory && currentBranch && setBaseOverride(effectiveDirectory, currentBranch, branch)}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
>
<Icon name="git-branch" className="size-3.5 text-primary" />
<span className="truncate typography-ui-label text-foreground" title={branch}>{branch}</span>
</button>
))}
</div>
)}
</ScrollableOverlay>
<div className="max-w-sm typography-micro text-muted-foreground">{t('gitView.pr.toast.baseBranchRequired')}</div>
</div>
);
}
@@ -2065,7 +2098,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{activeDiffScope === 'turn' ? t('diffView.state.noLastTurnChanges')
: activeDiffScope === 'branch' && branchBase ? t('diffView.branch.empty', { base: branchBase })
: activeDiffScope === 'commit' ? t('commitComparison.emptyDiff')
: activeDiffScope === 'branch' && branchBase ? t('diffView.branch.empty', { base: branchRefLabel(branchBase) })
: t('diffView.state.cleanWorkingTree')}
</div>
);
@@ -2088,13 +2122,15 @@ export const DiffView: React.FC<DiffViewProps> = ({
/>
) : null}
{!isMobile && (
activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' || activeDiffScope === 'branch' ? (
activeDiffScope !== 'all' ? (
<ChangeScopeSelector
scope={activeDiffScope}
workingCount={workingFileCount}
stagedCount={stagedFileCount}
turnCount={turnFileCount}
branchCount={branchFileCount}
commitCount={commitFiles?.length ?? null}
showCommitOption={!isVSCodeRuntime()}
showBranchOption={showBranchOption}
onScopeChange={(scope) => {
setActiveDiffScope(scope);
@@ -2113,6 +2149,28 @@ export const DiffView: React.FC<DiffViewProps> = ({
</div>
)
)}
{activeDiffScope === 'branch' && (
<BranchComparisonSelector
key={JSON.stringify([effectiveDirectory, currentBranch])}
branches={branches?.all ?? []}
currentBranch={currentBranch}
base={branchBase}
onSelect={(base) => {
if (effectiveDirectory && currentBranch) setBaseOverride(effectiveDirectory, currentBranch, base);
}}
/>
)}
{activeDiffScope === 'commit' && (
<CommitComparisonSelector
key={JSON.stringify([effectiveDirectory, currentBranch])}
commits={commitComparison.commits}
selectedHash={selectedCommitHash}
loading={commitComparison.loading}
error={commitComparison.error}
onSelect={commitComparison.select}
onRefresh={() => void commitComparison.refresh()}
/>
)}
{changedFiles.length > 0 && (
<Button
variant="ghost"
@@ -2161,7 +2219,13 @@ export const DiffView: React.FC<DiffViewProps> = ({
// while looking at staged changes should review
// staged changes, not whatever the panel showed last.
const directory = effectiveDirectory ?? '';
requestWalkthroughSource(directory, {
requestWalkthroughSource(directory, activeDiffScope === 'commit' && selectedCommitHash ? {
kind: 'commit', hash: selectedCommitHash,
} : activeDiffScope === 'branch' && branchBase && currentBranch ? {
kind: 'branch',
baseRef: branchBase,
headRef: currentBranch,
} : {
kind: 'working-tree',
scope: activeDiffScope === 'staged' || activeDiffScope === 'working'
? activeDiffScope
@@ -219,6 +219,48 @@ const installMinimalDom = () => {
};
describe('useRangeKeyedCache', () => {
test('refreshes visible paths on revision changes without blanking completed diffs or refetching on rerender', async () => {
const dom = installMinimalDom();
const root = createRoot(dom.container);
let revision = '1';
let paths = 'a.ts';
const requests: Array<{ path: string; result: ReturnType<typeof deferred<string>> }> = [];
type CapturedEntries = { entries: ReadonlyMap<string, string> | null };
const captured: CapturedEntries = { entries: null };
const Harness = () => {
captured.entries = useRangeKeyedCache('range', paths, (path) => {
const result = deferred<string>();
requests.push({ path, result });
return result.promise;
}, 'loading', revision);
return null;
};
try {
await act(async () => root.render(React.createElement(Harness)));
await act(async () => requests[0].result.resolve('old'));
await act(async () => root.render(React.createElement(Harness)));
expect(requests).toHaveLength(1);
revision = '2';
await act(async () => root.render(React.createElement(Harness)));
expect(requests).toHaveLength(2);
expect(captured.entries?.get('a.ts')).toBe('old');
// Changing visible paths cancels the stale refresh and must retry it.
paths = 'a.ts\0b.ts';
await act(async () => root.render(React.createElement(Harness)));
expect(requests.map(({ path }) => path)).toEqual(['a.ts', 'a.ts', 'a.ts', 'b.ts']);
await act(async () => {
requests[2].result.resolve('current');
requests[3].result.resolve('new file');
requests[1].result.resolve('stale');
});
expect(captured.entries?.get('a.ts')).toBe('current');
expect(captured.entries?.get('b.ts')).toBe('new file');
} finally {
await act(async () => root.unmount());
dom.restore();
}
});
test('a stale completion from the previous range cannot write into the new range', async () => {
const dom = installMinimalDom();
const root: Root = createRoot(dom.container);
@@ -141,16 +141,23 @@ export const useBoundedDirectoryRetry = (
* old `fetchEntry` promise resolves (or rejects) after the range switched.
* - Reservations that never completed are released on cleanup so a later run
* retries those paths instead of showing the placeholder forever.
* - A revision change refreshes requested paths while retaining completed
* same-range values until replacement. Unchanged revisions do no extra work;
* closed paths refresh when requested again. Range switches hide old values
* immediately, before effects run.
*/
export const useRangeKeyedCache = <T>(
rangeKey: string | null,
pathsKey: string,
fetchEntry: ((path: string) => Promise<T>) | null,
placeholder: T
placeholder: T,
revision = ''
): ReadonlyMap<string, T> => {
const [entries, setEntries] = React.useState<Map<string, T>>(() => new Map());
const entriesRef = React.useRef(entries);
entriesRef.current = entries;
const completedRevisions = React.useRef(new Map<string, string>());
const entriesRangeKey = React.useRef<string | null>(null);
// The fetcher is read through a ref so a caller passing an inline arrow (a
// new function every render) cannot restart the fetch effect in a loop.
@@ -169,7 +176,8 @@ export const useRangeKeyedCache = <T>(
}, []);
React.useEffect(() => {
if (!rangeKey) return;
entriesRangeKey.current = rangeKey;
completedRevisions.current.clear();
entriesRef.current = new Map();
setEntries(entriesRef.current);
}, [rangeKey]);
@@ -181,31 +189,34 @@ export const useRangeKeyedCache = <T>(
}
let cancelled = false;
const pendingReservations = new Set<string>();
const revisions = completedRevisions.current;
for (const path of pathsKey.split('\0')) {
if (entriesRef.current.has(path)) continue;
if (entriesRef.current.has(path) && revisions.get(path) === revision) continue;
pendingReservations.add(path);
writeEntry(path, placeholder);
if (!entriesRef.current.has(path)) writeEntry(path, placeholder);
fetcher(path)
.then((value) => {
if (cancelled) return;
pendingReservations.delete(path);
revisions.set(path, revision);
writeEntry(path, value);
})
.catch(() => {
if (cancelled) return;
// Release the reservation so a later run can retry this path.
pendingReservations.delete(path);
revisions.delete(path);
writeEntry(path, null);
});
}
return () => {
cancelled = true;
for (const path of pendingReservations) {
writeEntry(path, null);
if (!revisions.has(path)) writeEntry(path, null);
}
};
}, [pathsKey, placeholder, rangeKey, writeEntry]);
}, [pathsKey, placeholder, rangeKey, revision, writeEntry]);
return entries;
return entriesRangeKey.current === rangeKey ? entries : new Map();
};
@@ -0,0 +1,106 @@
import React, { act, useState } from 'react';
import { test, expect } from 'bun:test';
import { Window } from 'happy-dom';
test('allows repeated base selection with search and keyboard navigation', async () => {
const dom = new Window({ url: 'http://localhost' });
const originals = new Map<string, PropertyDescriptor | undefined>();
const globals = {
window: dom,
document: dom.document,
navigator: dom.navigator,
location: dom.location,
Element: dom.Element,
HTMLElement: dom.HTMLElement,
HTMLInputElement: dom.HTMLInputElement,
Node: dom.Node,
Event: dom.Event,
KeyboardEvent: dom.KeyboardEvent,
MouseEvent: dom.MouseEvent,
MutationObserver: dom.MutationObserver,
ResizeObserver: dom.ResizeObserver,
getComputedStyle: dom.getComputedStyle.bind(dom),
requestAnimationFrame: dom.requestAnimationFrame.bind(dom),
cancelAnimationFrame: dom.cancelAnimationFrame.bind(dom),
IS_REACT_ACT_ENVIRONMENT: true,
};
for (const [name, value] of Object.entries(globals)) {
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
}
const { createRoot } = await import('react-dom/client');
const { BranchComparisonSelector } = await import('./BranchComparisonSelector');
const { I18nProvider } = await import('@/lib/i18n');
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
const choices: string[] = [];
function Harness() {
const [base, setBase] = useState<string | null>(null);
return <BranchComparisonSelector
branches={['feature', 'main', 'parent', 'remotes/origin/main']}
currentBranch="feature"
base={base}
onSelect={(ref) => { choices.push(ref); setBase(ref); }}
/>;
}
const trigger = () => {
const button = container.querySelector('button');
if (!button) throw new Error('Missing branch trigger');
return button;
};
const selectedRef = () => document.querySelector('[cmdk-item][data-selected="true"]')?.getAttribute('data-value');
const press = async (key: string, ctrlKey = false) => {
const input = document.querySelector('input');
if (!input) throw new Error('Missing branch search');
await act(async () => {
input.dispatchEvent(new KeyboardEvent('keydown', { key, ctrlKey, bubbles: true, cancelable: true }));
});
};
try {
await act(async () => root.render(<I18nProvider><Harness /></I18nProvider>));
await act(async () => trigger().click());
expect(document.querySelector('[data-value="refs/heads/feature"]')).toBeNull();
await press('ArrowDown');
const afterDown = selectedRef();
expect(afterDown).toBe('refs/heads/parent');
await press('ArrowUp');
await press('n', true);
expect(selectedRef()).toBe(afterDown);
await press('p', true);
await press('Enter');
expect(choices).toHaveLength(1);
expect(trigger().textContent).toContain('main');
await act(async () => trigger().click());
const input = document.querySelector('input');
if (!input) throw new Error('Missing branch search');
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, 'value')?.set;
setter?.call(input, 'parent');
input.dispatchEvent(new Event('input', { bubbles: true }));
});
expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(1);
await press('Enter');
expect(choices).toEqual(['refs/heads/main', 'refs/heads/parent']);
expect(trigger().textContent).toContain('parent');
await act(async () => trigger().click());
expect(document.querySelector('input')?.value).toBe('');
const remote = document.querySelector<HTMLElement>('[data-value="refs/remotes/origin/main"]');
if (!remote) throw new Error('Missing remote branch');
await act(async () => remote.click());
expect(choices.at(-1)).toBe('refs/remotes/origin/main');
expect(trigger().textContent).toContain('origin/main');
await act(async () => {
trigger().dispatchEvent(new KeyboardEvent('keydown', { key: 'n', ctrlKey: true, bubbles: true }));
});
expect(document.querySelector('input')).toBeNull();
} finally {
await act(async () => root.unmount());
await dom.happyDOM.abort();
for (const [name, descriptor] of originals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
}
});
@@ -0,0 +1,83 @@
import { useState } from 'react';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { useI18n } from '@/lib/i18n';
import { rankByQuery } from '@/lib/search/fuzzySearch';
import { cn } from '@/lib/utils';
import { branchRefLabel } from './baseBranch';
interface BranchComparisonSelectorProps {
branches: readonly string[];
currentBranch: string | null;
base: string | null;
onSelect: (ref: string) => void;
}
export function BranchComparisonSelector({ branches, currentBranch, base, onSelect }: BranchComparisonSelectorProps) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const label = base ? branchRefLabel(base) : t('gitView.pr.field.baseBranch');
return (
<DropdownMenu open={open} onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) setSearch('');
}}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className={cn(dropdownTriggerVariants({ size: 'sm' }), 'min-w-0 max-w-48')}
aria-label={t('gitView.pr.field.baseBranch')}
title={label}
disabled={!currentBranch}
>
<Icon name="git-branch" className="size-3.5" />
<span className="truncate">{label}</span>
<Icon name="arrow-down-s" className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72 max-w-[calc(100vw-2rem)] p-0">
<Command shouldFilter={false} onKeyDown={(event) => {
if (event.key !== 'Escape') event.stopPropagation();
}}>
<CommandInput
autoFocus
value={search}
onValueChange={setSearch}
placeholder={t('gitView.branch.searchPlaceholder')}
aria-label={t('gitView.branch.searchPlaceholder')}
/>
<CommandList>
<CommandEmpty>{t('gitView.branch.empty')}</CommandEmpty>
<CommandGroup>
{open && rankByQuery(
[...new Set(branches)]
.filter((name) => name !== currentBranch)
.sort()
.map((name) => ({
ref: name.startsWith('remotes/') ? `refs/${name}` : `refs/heads/${name}`,
label: branchRefLabel(name),
})),
search,
(branch) => [branch.label],
).map((branch) => (
<CommandItem key={branch.ref} value={branch.ref} onSelect={() => {
onSelect(branch.ref);
setOpen(false);
setSearch('');
}}>
<span className="min-w-0 flex-1 truncate" title={branch.ref}>{branch.label}</span>
{(branch.ref === base || branch.label === base) && <Icon name="check" className="size-3.5" />}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,88 @@
import React, { act, useState } from 'react';
import { expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import type { GitLogEntry } from '@/lib/api/types';
test('shows commit metadata and shares repeated searched selections between two pickers', async () => {
const dom = new Window({ url: 'http://localhost' });
const originals = new Map<string, PropertyDescriptor | undefined>();
const globals = {
window: dom, document: dom.document, navigator: dom.navigator, location: dom.location,
Element: dom.Element, HTMLElement: dom.HTMLElement, HTMLInputElement: dom.HTMLInputElement,
Node: dom.Node, Event: dom.Event, KeyboardEvent: dom.KeyboardEvent, MouseEvent: dom.MouseEvent,
MutationObserver: dom.MutationObserver, ResizeObserver: dom.ResizeObserver,
getComputedStyle: dom.getComputedStyle.bind(dom), requestAnimationFrame: dom.requestAnimationFrame.bind(dom),
cancelAnimationFrame: dom.cancelAnimationFrame.bind(dom), IS_REACT_ACT_ENVIRONMENT: true,
};
for (const [name, value] of Object.entries(globals)) {
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
}
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('@/lib/i18n');
const { CommitComparisonSelector } = await import('./CommitComparisonSelector');
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
const commits: GitLogEntry[] = ['a', 'b'].map((letter, index) => ({
hash: letter.repeat(40), message: index === 0 ? 'fix: first commit' : 'feat: second commit',
author_name: 'Test Author', author_email: 'test@example.com', date: '2026-09-09T09:22:00Z',
body: '', refs: '', parents: [], filesChanged: 1, insertions: 2, deletions: 1,
}));
const selected: string[] = [];
let refreshes = 0;
function Harness() {
const [hash, setHash] = useState<string | null>(null);
return <>{['changes', 'walkthrough'].map((name) => <section key={name} data-picker={name}>
<CommitComparisonSelector commits={commits} selectedHash={hash} loading={false} error={null}
onRefresh={() => { refreshes += 1; }}
onSelect={(commit) => { selected.push(commit.hash); setHash(commit.hash); }} />
</section>)}</>;
}
const trigger = (name: string) => {
const button = container.querySelector<HTMLButtonElement>(`[data-picker="${name}"] button`);
if (!button) throw new Error('Missing commit picker');
return button;
};
const input = () => {
const value = document.querySelector('input');
if (!value) throw new Error('Missing commit search');
return value;
};
const press = async (key: string, ctrlKey = false) => {
await act(async () => { input().dispatchEvent(new KeyboardEvent('keydown', { key, ctrlKey, bubbles: true, cancelable: true })); });
};
try {
await act(async () => root.render(<I18nProvider><Harness /></I18nProvider>));
await act(async () => trigger('changes').click());
const first = document.querySelector('[cmdk-item]');
expect(first?.textContent).toContain('fix: first commit');
expect(first?.textContent).toContain('Test Author');
expect(first?.textContent).toContain('2026');
expect(first?.textContent).toContain('aaaaaaaa');
await press('ArrowDown');
expect(document.querySelector('[cmdk-item][data-selected="true"]')?.getAttribute('data-value')).toBe('b'.repeat(40));
await press('p', true);
await press('n', true);
await press('Enter');
expect(trigger('walkthrough').textContent).toContain('bbbbbbbb');
await act(async () => trigger('walkthrough').click());
await act(async () => {
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, 'value')?.set?.call(input(), 'first');
input().dispatchEvent(new Event('input', { bubbles: true }));
});
expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(1);
await press('Enter');
expect(trigger('changes').textContent).toContain('aaaaaaaa');
expect(selected).toEqual(['b'.repeat(40), 'a'.repeat(40)]);
expect(refreshes).toBe(2);
} finally {
await act(async () => root.unmount());
await dom.happyDOM.abort();
for (const [name, descriptor] of originals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
}
});
@@ -0,0 +1,79 @@
import { useState } from 'react';
import type { GitLogEntry } from '@/lib/api/types';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { useI18n } from '@/lib/i18n';
import { rankByQuery } from '@/lib/search/fuzzySearch';
import { formatDateTimeForPreference } from '@/lib/timeFormat';
import { useUIStore } from '@/stores/useUIStore';
import { cn } from '@/lib/utils';
interface CommitComparisonSelectorProps {
commits: readonly GitLogEntry[];
selectedHash: string | null;
loading: boolean;
error: string | null;
onSelect: (commit: GitLogEntry) => void;
onRefresh: () => void;
}
export function CommitComparisonSelector({ commits, selectedHash, loading, error, onSelect, onRefresh }: CommitComparisonSelectorProps) {
const { t } = useI18n();
const timeFormat = useUIStore((state) => state.timeFormatPreference);
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
return (
<DropdownMenu open={open} onOpenChange={(value) => {
setOpen(value);
if (!value) setSearch('');
else if (!loading) onRefresh();
}}>
<DropdownMenuTrigger asChild>
<Button variant="outline" className={cn(dropdownTriggerVariants({ size: 'sm' }), 'min-w-0 max-w-48')}
aria-label={t('commitComparison.select')} title={selectedHash ?? t('commitComparison.select')}>
<Icon name="git-commit" className="size-3.5" />
<span className="truncate">{selectedHash?.slice(0, 8) ?? t('commitComparison.select')}</span>
<Icon name="arrow-down-s" className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[32rem] max-w-[calc(100vw-2rem)] p-0">
<Command shouldFilter={false} onKeyDown={(event) => { if (event.key !== 'Escape') event.stopPropagation(); }}>
<CommandInput autoFocus value={search} onValueChange={setSearch} placeholder={t('commitComparison.search')} aria-label={t('commitComparison.search')} />
{loading ? (
<div className="flex items-center justify-center gap-2 p-4 typography-meta text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />{t('diffView.state.loadingChanges')}
</div>
) : error ? (
<div className="flex flex-col items-center gap-2 p-4 typography-meta text-muted-foreground">
<span>{t('commitComparison.loadError')}</span>
<span className="max-w-full break-words">{error}</span>
<Button variant="outline" size="sm" onClick={onRefresh}>{t('diffView.actions.retry')}</Button>
</div>
) : (
<CommandList>
<CommandEmpty>{t('commitComparison.noCommits')}</CommandEmpty>
<CommandGroup>
{open && rankByQuery(commits, search, (commit) => [commit.message, commit.author_name, commit.hash]).map((commit) => (
<CommandItem key={commit.hash} value={commit.hash} onSelect={() => { onSelect(commit); setOpen(false); setSearch(''); }}>
<div className="min-w-0 flex-1">
<div className="truncate typography-ui-label font-semibold" title={commit.message}>{commit.message}</div>
<div className="truncate typography-meta text-muted-foreground">
{commit.author_name} · {Number.isNaN(new Date(commit.date).getTime()) ? commit.date : formatDateTimeForPreference(new Date(commit.date), timeFormat, {
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
})} · {commit.hash.slice(0, 8)}
</div>
</div>
{commit.hash === selectedHash && <Icon name="check" className="size-3.5" />}
</CommandItem>
))}
</CommandGroup>
</CommandList>
)}
</Command>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,3 +1,5 @@
export const branchRefLabel = (ref: string): string => ref.replace(/^refs\/(heads|remotes)\//, '').replace(/^remotes\//, '');
/**
* Derives the base ("target") branch a feature branch should compare and
* merge against. Shared by GitView and the standalone pull-request surface so
@@ -16,7 +16,11 @@ import { openExternalUrl } from '@/lib/url';
import { buildWalkthroughView } from '@/lib/walkthrough/model';
import type { WalkthroughSource, WalkthroughWorkingTreeScope } from '@/lib/walkthrough/types';
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
import { deriveBaseBranch, hasResolvableBaseBranch } from '@/components/views/git/baseBranch';
import { useBranchComparisonBase } from '@/hooks/useBranchComparisonBase';
import { useCommitComparison } from '@/hooks/useCommitComparison';
import { CommitComparisonSelector } from '@/components/views/git/CommitComparisonSelector';
import { BranchComparisonSelector } from '@/components/views/git/BranchComparisonSelector';
import { useGitBaseBranchStore } from '@/stores/useGitBaseBranchStore';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { useConfigStore } from '@/stores/useConfigStore';
import { useGitBranches, useGitStatus, useGitStore, useIsGitRepo } from '@/stores/useGitStore';
@@ -159,6 +163,7 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
[setStoredTocWidth, tocWidth]
);
const [scope, setScope] = useState<WalkthroughWorkingTreeScope>('all');
const [pendingSourceSelection, setPendingSourceSelection] = useState<{ directory: string; kind: 'branch' | 'commit' } | null>(null);
const [activeStopId, setActiveStopId] = useState<string | null>(null);
const [scrollToStopId, setScrollToStopId] = useState<string | null>(null);
const [visitedStopIds, setVisitedStopIds] = useState<ReadonlySet<string>>(() => new Set());
@@ -175,6 +180,7 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
const status = useGitStatus(directory || null);
const branches = useGitBranches(directory || null);
const setBaseOverride = useGitBaseBranchStore((state) => state.setOverride);
const ensureAll = useGitStore((state) => state.ensureAll);
const { github, git } = useRuntimeAPIs();
@@ -182,37 +188,18 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
if (directory) void ensureAll(directory, git);
}, [directory, ensureAll, git]);
// The branch source reviews everything on this branch that is not on its
// base. Three-dot semantics server-side mean merges from the base are
// already excluded.
// Changes and walkthrough share the explicit base choice and reflog detection.
const currentBranch = status?.current ?? null;
const choosingCommit = pendingSourceSelection?.directory === directory && pendingSourceSelection.kind === 'commit' && !requestedSource;
const isCommitScope = choosingCommit || requestedSource?.kind === 'commit';
const commitComparison = useCommitComparison(directory || null, currentBranch, visible && isCommitScope,
requestedSource?.kind === 'commit' ? requestedSource.hash : undefined);
const selectedCommitHash = commitComparison.selectedCommit?.hash ?? (requestedSource?.kind === 'commit' ? requestedSource.hash : null);
const { base: comparisonBase, resolved: comparisonBaseResolved, revision: branchRevision } = useBranchComparisonBase(directory || null, currentBranch, visible);
const branchSource = useMemo<WalkthroughSource | null>(() => {
const headRef = currentBranch;
if (!headRef) return null;
const all = branches?.all ?? [];
const localBranches = all.filter((name) => !name.startsWith('remotes/'));
const remoteBranches = all
.filter((name) => name.startsWith('remotes/'))
.map((name) => name.slice('remotes/'.length));
const remoteNames = new Set(
remoteBranches
.map((name) => name.split('/')[0])
.filter(Boolean)
);
const trackingRemote = status?.tracking?.split('/')[0];
const defaultBranch = (trackingRemote && branches?.defaultBranches?.[trackingRemote])
?? branches?.defaultBranches?.origin;
const baseRef = deriveBaseBranch({
remoteNames,
localBranches,
defaultBranch,
headBranch: headRef,
});
if (!baseRef || baseRef === headRef || !hasResolvableBaseBranch({ baseBranch: baseRef, localBranches, remoteBranches })) {
return null;
}
return { kind: 'branch', baseRef, headRef };
}, [branches, currentBranch, status?.tracking]);
if (!currentBranch || !comparisonBase || comparisonBase === currentBranch) return null;
return { kind: 'branch', baseRef: comparisonBase, headRef: currentBranch };
}, [comparisonBase, currentBranch]);
// The pull request for this branch used to appear only after visiting the PR
// panel, because nothing else asked GitHub about it. Ask here too: the status
@@ -258,9 +245,18 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
));
const source = useMemo<WalkthroughSource>(
() => requestedSource ?? { kind: 'working-tree', scope },
[requestedSource, scope]
() => isCommitScope && selectedCommitHash
? { kind: 'commit', hash: selectedCommitHash }
: requestedSource?.kind === 'branch'
? branchSource ?? requestedSource
: requestedSource ?? { kind: 'working-tree', scope },
[branchSource, isCommitScope, requestedSource, scope, selectedCommitHash]
);
const choosingBranchBase = pendingSourceSelection?.directory === directory && pendingSourceSelection.kind === 'branch' && !requestedSource;
const isBranchScope = choosingBranchBase || source.kind === 'branch';
const branchNeedsBase = choosingBranchBase || (source.kind === 'branch' && !branchSource);
const commitNeedsSelection = choosingCommit || (isCommitScope && !selectedCommitHash);
const needsSourceSelection = branchNeedsBase || commitNeedsSelection;
// Offer whichever pull request we know about: the one already selected, or
// the one this branch has.
@@ -271,6 +267,7 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
const selectWorkingTree = useCallback(
(value: WalkthroughWorkingTreeScope) => {
setPendingSourceSelection(null);
clearRequestedSource(directory);
setScope(value);
},
@@ -281,6 +278,16 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
const generate = useWalkthroughStore((state) => state.generate);
const cancel = useWalkthroughStore((state) => state.cancel);
const requestSource = useWalkthroughStore((state) => state.requestSource);
useEffect(() => {
if (!choosingBranchBase || !branchSource) return;
requestSource(directory, branchSource);
setPendingSourceSelection(null);
}, [branchSource, choosingBranchBase, directory, requestSource]);
useEffect(() => {
if (!choosingCommit || !selectedCommitHash) return;
requestSource(directory, { kind: 'commit', hash: selectedCommitHash });
setPendingSourceSelection(null);
}, [choosingCommit, directory, requestSource, selectedCommitHash]);
const selectModel = useWalkthroughStore((state) => state.selectModel);
const selectedModel = useWalkthroughStore((state) => state.getSelectedModel(directory, source));
const selectLanguage = useWalkthroughStore((state) => state.selectLanguage);
@@ -302,11 +309,12 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
// Reloads on a model or language change: whether this diff fits, and whether
// the model can produce structured output, are answers about a specific
// request — and the language instruction is part of that request.
const sourceRevision = source.kind === 'branch' ? branchRevision : '';
useEffect(() => {
void load(directory, source, { language: activeLanguage });
}, [activeLanguage, directory, load, source, selectedModel]);
if (visible && !needsSourceSelection) void load(directory, source, { language: activeLanguage });
}, [activeLanguage, needsSourceSelection, directory, load, source, selectedModel, sourceRevision, visible]);
const view = useMemo(() => buildWalkthroughView(entry.result), [entry.result]);
const view = useMemo(() => needsSourceSelection ? null : buildWalkthroughView(entry.result), [needsSourceSelection, entry.result]);
// A new walkthrough is a new reading path: keeping the old progress would
// mark stops as visited that the user has never seen.
@@ -349,8 +357,8 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
const [sourceMenuOpen, setSourceMenuOpen] = useState(false);
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
const sourceValue = source.kind === 'working-tree' ? source.scope : source.kind;
const sourceLabel = source.kind === 'branch'
const sourceValue = isCommitScope ? 'commit' : isBranchScope ? 'branch' : source.kind === 'working-tree' ? source.scope : source.kind;
const sourceLabel = isCommitScope ? t('commitComparison.mode') : isBranchScope
? t('walkthrough.scope.branch')
: source.kind === 'pr'
? t('walkthrough.scope.pullRequest', { number: source.number })
@@ -489,7 +497,7 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
// Not ready, or no usable selected model, means Generate must not look
// actionable — including when the resolved model has no login.
const generateDisabled = !activeModel || Boolean(entry.readiness && !entry.readiness.ready);
const generateDisabled = needsSourceSelection || !activeModel || Boolean(entry.readiness && !entry.readiness.ready);
const handleGenerate = useCallback(
(force: boolean) => {
@@ -547,11 +555,23 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
value={sourceValue}
onValueChange={(value) => {
setSourceMenuOpen(false);
if (value === 'commit') {
clearRequestedSource(directory);
setPendingSourceSelection({ directory, kind: 'commit' });
return;
}
if (value === 'branch') {
if (branchSource) requestSource(directory, branchSource);
if (branchSource) {
setPendingSourceSelection(null);
requestSource(directory, branchSource);
} else {
clearRequestedSource(directory);
setPendingSourceSelection({ directory, kind: 'branch' });
}
return;
}
if (value === 'pr') {
setPendingSourceSelection(null);
if (prSource) requestSource(directory, prSource);
return;
}
@@ -573,19 +593,21 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
: t('walkthrough.scope.working')}
</DropdownMenuRadioItem>
))}
{(branchSource || prSource) && (
{currentBranch && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className={SCOPE_GROUP_LABEL_CLASS}>
{t('walkthrough.scope.group.committed')}
</DropdownMenuLabel>
<DropdownMenuRadioItem value="branch">
{t('walkthrough.scope.branch')}
</DropdownMenuRadioItem>
</>
)}
{branchSource && (
<DropdownMenuRadioItem value="branch">
{t('walkthrough.scope.branch')}
</DropdownMenuRadioItem>
)}
<DropdownMenuSeparator />
<DropdownMenuLabel className={SCOPE_GROUP_LABEL_CLASS}>
{t('walkthrough.scope.group.committed')}
</DropdownMenuLabel>
<DropdownMenuRadioItem value="commit">
{t('commitComparison.mode')}
</DropdownMenuRadioItem>
{prSource && (
<DropdownMenuRadioItem value="pr">
{t('walkthrough.scope.pullRequest', { number: prSource.number })}
@@ -595,6 +617,30 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
</DropdownMenuContent>
</DropdownMenu>
{isBranchScope && (
<BranchComparisonSelector
key={JSON.stringify([directory, currentBranch])}
branches={branches?.all ?? []}
currentBranch={currentBranch}
base={comparisonBase}
onSelect={(base) => {
if (directory && currentBranch) setBaseOverride(directory, currentBranch, base);
}}
/>
)}
{isCommitScope && (
<CommitComparisonSelector
key={JSON.stringify([directory, currentBranch])}
commits={commitComparison.commits}
selectedHash={selectedCommitHash}
loading={commitComparison.loading}
error={commitComparison.error}
onSelect={commitComparison.select}
onRefresh={() => void commitComparison.refresh()}
/>
)}
<div className="ml-auto flex min-w-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
@@ -814,7 +860,22 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
)}
<div className={cn('flex min-h-0 flex-1', showToc ? 'flex-row' : 'flex-col')}>
{blockedReason ? (
{commitNeedsSelection ? (
<div className="flex flex-1 items-center justify-center gap-2 p-8 typography-meta text-muted-foreground">
{commitComparison.loading ? <Icon name="loader-4" className="size-6 animate-spin" />
: commitComparison.error ?? t('commitComparison.noCommits')}
</div>
) : branchNeedsBase ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
{!comparisonBaseResolved && currentBranch ? (
<Icon name="loader-4" className="size-6 animate-spin text-muted-foreground" />
) : (
<p className="typography-meta text-muted-foreground">
{t('gitView.pr.toast.baseBranchRequired')}
</p>
)}
</div>
) : blockedReason ? (
<WalkthroughBlocker
reason={blockedReason}
model={blockedModel}
@@ -0,0 +1,40 @@
import { useEffect, useState } from 'react';
import { getBranchBase } from '@/lib/gitApi';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { gitBaseBranchEntryKey, useGitBaseBranchStore } from '@/stores/useGitBaseBranchStore';
import { useGitStore } from '@/stores/useGitStore';
/** Shared base and freshness identity for Changes and the current-branch walkthrough. */
export function useBranchComparisonBase(directory: string | null, branch: string | null, enabled: boolean) {
const runtimeKey = useGitStore((state) => state.runtimeKey);
const statusFetchedAt = useGitStore((state) => enabled && directory
? state.directories.get(directory)?.lastStatusFetch ?? 0
: 0);
const key = JSON.stringify([runtimeKey, directory, branch]);
const overrideKey = directory && branch ? gitBaseBranchEntryKey(directory, branch) : null;
const override = useGitBaseBranchStore((state) => overrideKey ? state.overrides[overrideKey] ?? null : null);
const [detected, setDetected] = useState<{ key: string; base: string | null } | null>(null);
useEffect(() => {
if (!enabled || !directory || !branch || override) return;
let cancelled = false;
const requestRuntime = getRuntimeKey();
getBranchBase(directory, branch)
.then(({ base }) => {
if (cancelled || getRuntimeKey() !== requestRuntime) return;
setDetected((previous) => previous?.key === key && previous.base === base ? previous : { key, base });
})
.catch(() => {
if (cancelled || getRuntimeKey() !== requestRuntime) return;
// Keep a same-branch answer on transient failure; a new branch needs a choice.
setDetected((previous) => previous?.key === key ? previous : { key, base: null });
});
return () => { cancelled = true; };
}, [branch, directory, enabled, key, override, statusFetchedAt]);
return {
base: override ?? (detected?.key === key ? detected.base : null),
resolved: Boolean(override) || detected?.key === key,
revision: JSON.stringify([runtimeKey, statusFetchedAt]),
};
}
@@ -0,0 +1,62 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { GitLogEntry } from '@/lib/api/types';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { useI18n } from '@/lib/i18n';
import { useGitStore } from '@/stores/useGitStore';
import { commitSelectionKey, useCommitSelectionStore } from '@/stores/useCommitSelectionStore';
import { useRuntimeAPIs } from './useRuntimeAPIs';
type CommitHistory =
| { key: string; status: 'loading' }
| { key: string; status: 'ready'; commits: GitLogEntry[] }
| { key: string; status: 'error'; message: string };
const NO_COMMITS: GitLogEntry[] = [];
export function useCommitComparison(directory: string | null, branch: string | null, enabled: boolean, preferredHash?: string) {
const { git } = useRuntimeAPIs();
const { t } = useI18n();
const runtimeKey = useGitStore((state) => state.runtimeKey);
const key = commitSelectionKey(directory ?? '', branch, runtimeKey);
const selectedCommit = useCommitSelectionStore((state) => state.selections.get(key) ?? null);
const selectCommit = useCommitSelectionStore((state) => state.select);
const [history, setHistory] = useState<CommitHistory | null>(null);
const requestId = useRef(0);
const preferredHashRef = useRef(preferredHash);
preferredHashRef.current = preferredHash;
const refresh = useCallback(async () => {
if (!directory || !enabled) return;
const id = ++requestId.current;
const requestRuntime = getRuntimeKey();
setHistory({ key, status: 'loading' });
try {
const result = await git.getGitLog(directory, { maxCount: 50, to: branch ? `refs/heads/${branch}` : 'HEAD' });
if (requestId.current !== id || getRuntimeKey() !== requestRuntime) return;
const commits = result.all.slice(0, 50);
setHistory({ key, status: 'ready', commits });
if (!useCommitSelectionStore.getState().selections.has(key)) {
const initial = preferredHashRef.current
? commits.find((commit) => commit.hash === preferredHashRef.current)
: commits[0];
if (initial) selectCommit(key, initial);
}
} catch (error) {
if (requestId.current !== id || getRuntimeKey() !== requestRuntime) return;
setHistory({ key, status: 'error', message: error instanceof Error ? error.message : t('commitComparison.loadError') });
}
}, [branch, directory, enabled, git, key, selectCommit, t]);
useEffect(() => {
void refresh();
return () => { requestId.current += 1; };
}, [refresh]);
const current = history?.key === key ? history : null;
return {
selectedCommit,
commits: current?.status === 'ready' ? current.commits : NO_COMMITS,
loading: enabled && (!current || current.status === 'loading'),
error: current?.status === 'error' ? current.message : null,
refresh,
select: (commit: GitLogEntry) => selectCommit(key, commit),
};
}
+70 -5
View File
@@ -917,11 +917,76 @@ html:not(.dark) .chat-scroll {
}
}
/* Assistant message footer: collapse text labels only on very narrow layouts. */
@container message-footer (max-width: 16rem) {
.message-footer__label {
display: none;
}
/* Assistant message footer: the run's facts collapse by priority instead of
wrapping. The model and the duration always stay the model truncates only
once even those two stop fitting while the thinking effort, the agent and
the timestamp drop out in that order as the row narrows. The optional group
wraps its overflow onto a second line that its single-line height clips
away, so a row that exists once per message costs no measurement. */
.message-footer__facts {
display: flex;
align-items: center;
min-width: 0;
overflow: hidden;
}
.message-footer__facts-core {
display: flex;
align-items: center;
gap: 0.375rem;
min-width: 0;
flex: 0 1 auto;
}
.message-footer__facts-optional {
display: flex;
flex-wrap: wrap;
align-content: flex-start;
align-items: center;
min-width: 0;
/* Takes only the width of its facts (no grow growing would push the
duration to the far edge and leave a hole after the agent) and gives that
width up long before the model starts truncating. */
flex: 0 9999 auto;
max-height: 1.25rem;
overflow: hidden;
}
/* A hairline item holds the start of the first line so that EVERY fact can
wrap off it. Without something already on the line, the browser keeps the
first fact there and the clip slices it in half instead of dropping it. */
.message-footer__facts-optional::before {
content: '';
flex: 0 0 1px;
/* Full line height: it has to define the first line, or the facts that wrap
past it land a pixel below and slip under the clip. */
height: 1.25rem;
}
/* The timestamp gives up its room before the agent and the effort do it is
the first fact the row can afford to lose (on touch it is not here at all:
it lives in the actions sheet, where it can never be lost). */
.message-footer__facts-optional--drops-first {
flex: 0 99999 auto;
}
/* The duration sits after the facts that can disappear and never shrinks. */
.message-footer__facts-duration {
display: flex;
align-items: center;
flex: 0 0 auto;
white-space: nowrap;
}
/* Each fact carries its own leading separator and spacing, so the group needs
no gap that would offset the zero-width holder. */
.message-footer__fact {
display: flex;
align-items: center;
gap: 0.375rem;
padding-inline-start: 0.375rem;
flex: 0 0 auto;
white-space: nowrap;
}
/* Animated tabs: collapse labels based on local container width. */
+14 -1
View File
@@ -190,18 +190,22 @@ export interface GetGitDiffOptions {
/**
* Diff between two refs. Uses three-dot (`base...head`) semantics server-side, so changes
* pulled into `head` by merging `base` are excluded only the branch's own work is returned.
* pulled into `head` by merging `base` are excluded. Refs are used as selected.
* includeWorkingTree compares that merge base with the checked-out branch's
* current files, including staged, unstaged, and untracked changes.
*/
export interface GetGitRangeDiffOptions {
base: string;
head: string;
path?: string;
contextLines?: number;
includeWorkingTree?: boolean;
}
export interface GetGitRangeFilesOptions {
base: string;
head: string;
includeWorkingTree?: boolean;
}
/** One changed file in a `base...head` range, with its change letter (A/M/D/R/C). */
@@ -388,6 +392,7 @@ export interface GitLogResponse {
export interface CommitFileEntry {
path: string;
previousPath?: string;
insertions: number;
deletions: number;
isBinary: boolean;
@@ -398,6 +403,13 @@ export interface GitCommitFilesResponse {
files: CommitFileEntry[];
}
export interface GetGitCommitDiffOptions {
hash: string;
path?: string;
previousPath?: string;
contextLines?: number;
}
export interface CommitFileDiffResponse {
original: string;
modified: string;
@@ -569,6 +581,7 @@ export interface GitAPI {
renameBranch(directory: string, oldName: string, newName: string): Promise<{ success: boolean; branch: string }>;
getGitLog(directory: string, options?: GitLogOptions): Promise<GitLogResponse>;
getCommitFiles(directory: string, hash: string): Promise<GitCommitFilesResponse>;
getGitCommitDiff?(directory: string, options: GetGitCommitDiffOptions): Promise<GitDiffResponse>;
getCommitFileDiff?(directory: string, hash: string, filePath: string, isBinary: boolean): Promise<CommitFileDiffResponse>;
getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null>;
hasLocalIdentity?(directory: string): Promise<boolean>;
+9
View File
@@ -984,6 +984,15 @@ export async function getCommitFiles(
return gitHttp.getCommitFiles(directory, hash);
}
export async function getGitCommitDiff(directory: string, options: import('./api/types').GetGitCommitDiffOptions): Promise<import('./api/types').GitDiffResponse> {
const runtime = getRuntimeGit();
if (runtime) {
if (!runtime.getGitCommitDiff) throw new Error('Commit comparisons are unavailable in this runtime');
return runtime.getGitCommitDiff(directory, options);
}
return gitHttp.getGitCommitDiff(directory, options);
}
export async function getCommitFileDiff(
directory: string,
hash: string,
+73
View File
@@ -13,6 +13,11 @@ import {
deleteRemoteBranch,
dropGitStash,
getGitBranches,
getGitRangeDiff,
getGitRangeFiles,
getGitCommitDiff,
getCommitFiles,
getGitLog,
getGitStatus,
gitFetch,
merge,
@@ -141,6 +146,74 @@ describe('gitApiHttp index mutations', () => {
});
});
describe('gitApiHttp branch comparisons', () => {
test('sends commit hashes and rename paths without trimming and rejects incomplete commit lists', async () => {
installWindowMock();
const urls: URL[] = [];
globalThis.fetch = Object.assign(async (input: RequestInfo | URL) => {
const url = new URL(String(input), 'http://localhost');
urls.push(url);
return Response.json(url.pathname.endsWith('/commit-diff') ? { diff: 'commit patch' } : { files: [{ path: 'incomplete' }] });
}, previousFetch);
try {
const hash = 'a'.repeat(40);
expect(await getGitCommitDiff('/repo', { hash, path: ' new\nfile.ts', previousPath: 'old.ts', contextLines: 20 }))
.toEqual({ diff: 'commit patch' });
expect(urls[0].pathname).toBe('/api/git/commit-diff');
expect(urls[0].searchParams.get('hash')).toBe(hash);
expect(urls[0].searchParams.get('path')).toBe(' new\nfile.ts');
expect(urls[0].searchParams.get('previousPath')).toBe('old.ts');
expect(urls[0].searchParams.get('context')).toBe('20');
await expect(getCommitFiles('/repo', hash)).rejects.toThrow();
await expect(getGitLog('/repo', { maxCount: 50, to: 'refs/heads/feature' })).rejects.toThrow();
expect(urls[2].searchParams.get('maxCount')).toBe('50');
expect(urls[2].searchParams.get('to')).toBe('refs/heads/feature');
expect(urls[2].searchParams.has('all')).toBe(false);
} finally {
restoreMocks();
}
});
test('sends the exact selected refs and working-tree option to both range endpoints', async () => {
installWindowMock();
const urls: URL[] = [];
globalThis.fetch = Object.assign(async (input: RequestInfo | URL) => {
const url = new URL(String(input), 'http://localhost');
urls.push(url);
return Response.json(url.pathname.endsWith('/range-files')
? { files: [{ path: 'new.ts', status: 'A' }] }
: { diff: 'current patch' });
}, previousFetch);
try {
const options = { base: 'refs/heads/parent', head: 'child', includeWorkingTree: true };
expect(await getGitRangeDiff('/repo', options)).toEqual({ diff: 'current patch' });
expect(await getGitRangeFiles('/repo', options)).toEqual([{ path: 'new.ts', status: 'A' }]);
expect(urls).toHaveLength(2);
for (const url of urls) {
expect(url.searchParams.get('base')).toBe('refs/heads/parent');
expect(url.searchParams.get('head')).toBe('child');
expect(url.searchParams.get('includeWorkingTree')).toBe('true');
}
} finally {
restoreMocks();
}
});
test('rejects malformed file lists and preserves the server ref error', async () => {
installWindowMock();
globalThis.fetch = Object.assign(async () => Response.json({ files: [{ path: 'new.ts' }] }), previousFetch);
const options = { base: 'missing', head: 'child', includeWorkingTree: true };
try {
await expect(getGitRangeFiles('/repo', options)).rejects.toThrow();
globalThis.fetch = Object.assign(async () => Response.json({ error: 'Fetch the selected ref first.' }, { status: 500 }), previousFetch);
await expect(getGitRangeDiff('/repo', options)).rejects.toThrow('Fetch the selected ref first.');
await expect(getGitRangeFiles('/repo', options)).rejects.toThrow('Fetch the selected ref first.');
} finally {
restoreMocks();
}
});
});
describe('gitApiHttp status cache', () => {
test('a Git refresh hint invalidates the cached status before listeners fetch', async () => {
installWindowMock();
+42 -16
View File
@@ -1,9 +1,11 @@
import { z } from 'zod';
import type {
GitStatus,
GitDiffResponse,
GetGitDiffOptions,
GetGitRangeDiffOptions,
GetGitRangeFilesOptions,
GetGitCommitDiffOptions,
GitFileDiffResponse,
GetGitFileDiffOptions,
GitBranch,
@@ -43,6 +45,24 @@ import { getRuntimeKey } from './runtime-switch';
import { notifyGitStatusInvalidated, subscribeGitStatusInvalidations } from './gitStatusInvalidation';
const API_BASE = '/api/git';
const gitRangeDiffSchema = z.object({ diff: z.string() });
const gitRangeFilesSchema = z.object({ files: z.array(z.object({ path: z.string(), status: z.string() })) });
const gitRangeErrorSchema = z.object({ error: z.string() });
const gitCommitFilesSchema = z.object({ files: z.array(z.object({
path: z.string(), previousPath: z.string().optional(), changeType: z.string(),
insertions: z.number(), deletions: z.number(), isBinary: z.boolean(),
})) });
const gitLogEntrySchema = z.object({
hash: z.string(), date: z.string(), message: z.string(), refs: z.string(), body: z.string(),
author_name: z.string(), author_email: z.string(), filesChanged: z.number(),
insertions: z.number(), deletions: z.number(), parents: z.array(z.string()),
});
const gitLogSchema = z.object({ all: z.array(gitLogEntrySchema), latest: gitLogEntrySchema.nullable(), total: z.number() });
async function rangeResponseError(response: Response, fallback: string): Promise<Error> {
const parsed = gitRangeErrorSchema.safeParse(await response.json().catch(() => null));
return new Error(parsed.success ? parsed.data.error : `${fallback}: ${response.statusText}`);
}
const GIT_STATUS_CACHE_TTL_MS = 1200;
const GIT_REPO_CHECK_CACHE_TTL_MS = 5000;
const gitStatusCache = new Map<string, { value: GitStatus; expiresAt: number }>();
@@ -285,7 +305,7 @@ export async function getGitRangeDiff(
directory: string,
options: GetGitRangeDiffOptions
): Promise<GitDiffResponse> {
const { base, head, path, contextLines } = options;
const { base, head, path, contextLines, includeWorkingTree } = options;
if (!base || !head) {
throw new Error('base and head are required to fetch git range diff');
}
@@ -296,40 +316,46 @@ export async function getGitRangeDiff(
head,
path: path || undefined,
context: contextLines,
includeWorkingTree,
})
);
if (!response.ok) {
throw new Error(`Failed to get git range diff: ${response.statusText}`);
throw await rangeResponseError(response, 'Failed to get git range diff');
}
return response.json();
return gitRangeDiffSchema.parse(await response.json());
}
export async function getGitCommitDiff(directory: string, options: GetGitCommitDiffOptions): Promise<GitDiffResponse> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/commit-diff`, directory, {
hash: options.hash,
path: options.path,
previousPath: options.previousPath,
context: options.contextLines,
}));
if (!response.ok) throw await rangeResponseError(response, 'Failed to get commit diff');
return gitRangeDiffSchema.parse(await response.json());
}
export async function getGitRangeFiles(
directory: string,
options: GetGitRangeFilesOptions
): Promise<import('./api/types').GitRangeFileEntry[]> {
const { base, head } = options;
const { base, head, includeWorkingTree } = options;
if (!base || !head) {
throw new Error('base and head are required to fetch git range files');
}
const response = await runtimeFetch(
buildUrl(`${API_BASE}/range-files`, directory, { base, head })
buildUrl(`${API_BASE}/range-files`, directory, { base, head, includeWorkingTree })
);
if (!response.ok) {
throw new Error(`Failed to get git range files: ${response.statusText}`);
throw await rangeResponseError(response, 'Failed to get git range files');
}
const payload = (await response.json()) as { files?: unknown };
if (!Array.isArray(payload.files)) return [];
return payload.files.filter((entry): entry is import('./api/types').GitRangeFileEntry => {
if (!entry || typeof entry !== 'object') return false;
const candidate = entry as { path?: unknown; status?: unknown };
return typeof candidate.path === 'string' && typeof candidate.status === 'string';
});
return gitRangeFilesSchema.parse(await response.json()).files;
}
export async function getBranchBase(
@@ -944,7 +970,7 @@ export async function getGitLog(
const errorBody = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(`Failed to get git log: ${errorBody.error || response.statusText}`);
}
return response.json();
return gitLogSchema.parse(await response.json());
}
export async function getCommitFiles(
@@ -955,9 +981,9 @@ export async function getCommitFiles(
buildUrl(`${API_BASE}/commit-files`, directory, { hash })
);
if (!response.ok) {
throw new Error(`Failed to get commit files: ${response.statusText}`);
throw await rangeResponseError(response, 'Failed to get commit files');
}
return response.json();
return gitCommitFilesSchema.parse(await response.json());
}
export async function getCommitFileDiff(
+8
View File
@@ -3,6 +3,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict = {
'commitComparison.mode': 'Commit',
'commitComparison.select': 'Commit auswählen',
'commitComparison.search': 'Commits suchen...',
'commitComparison.loadError': 'Commits konnten nicht geladen werden',
'commitComparison.noCommits': 'Keine Commits gefunden',
'commitComparison.emptyDiff': 'Keine Änderungen in diesem Commit',
'chat.liveActivity.title': 'Aktivität',
'chat.liveActivity.changedFile': '{count} Datei geändert',
'chat.liveActivity.changedFiles': '{count} Dateien geändert',
@@ -2094,6 +2100,8 @@ export const dict = {
'chat.messageBody.actions.openPreviewAria': 'Vorschau öffnen',
'chat.messageBody.actions.openPreview': 'Vorschau öffnen',
'chat.messageBody.actions.copyAnswer': 'Antwort kopieren',
'chat.messageBody.actions.moreActions': 'Weitere Aktionen',
'chat.messageBody.toast.copied': 'In die Zwischenablage kopiert',
'chat.messageBody.actions.savingImage': 'Bild wird gespeichert...',
'chat.messageBody.actions.saveAsImage': 'Als Bild speichern',
'chat.messageBody.actions.saveAsPlan': 'Als Plan speichern',
+8
View File
@@ -3,6 +3,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict = {
'commitComparison.mode': 'Commit',
'commitComparison.select': 'Select commit',
'commitComparison.search': 'Search commits...',
'commitComparison.loadError': 'Failed to load commits',
'commitComparison.noCommits': 'No commits found',
'commitComparison.emptyDiff': 'No changes in this commit',
'chat.liveActivity.title': 'Activity',
'chat.liveActivity.changedFile': 'Changed {count} file',
'chat.liveActivity.changedFiles': 'Changed {count} files',
@@ -2314,6 +2320,8 @@ export const dict = {
'chat.messageBody.actions.openPreviewAria': 'Open preview',
'chat.messageBody.actions.openPreview': 'Open preview',
'chat.messageBody.actions.copyAnswer': 'Copy answer',
'chat.messageBody.actions.moreActions': 'More actions',
'chat.messageBody.toast.copied': 'Copied to clipboard',
'chat.messageBody.actions.savingImage': 'Saving image...',
'chat.messageBody.actions.saveAsImage': 'Save as image',
'chat.messageBody.actions.saveAsPlan': 'Save as plan',
+8
View File
@@ -4,6 +4,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'commitComparison.mode': 'Commit',
'commitComparison.select': 'Seleccionar commit',
'commitComparison.search': 'Buscar commits...',
'commitComparison.loadError': 'No se pudieron cargar los commits',
'commitComparison.noCommits': 'No se encontraron commits',
'commitComparison.emptyDiff': 'No hay cambios en este commit',
'chat.liveActivity.title': 'Actividad',
'chat.liveActivity.changedFile': '{count} archivo modificado',
'chat.liveActivity.changedFiles': '{count} archivos modificados',
@@ -2292,6 +2298,8 @@ export const dict: Record<I18nKey, string> = {
"chat.messageBody.actions.openPreviewAria": "Abrir vista previa",
"chat.messageBody.actions.openPreview": "Abrir vista previa",
"chat.messageBody.actions.copyAnswer": "Copiar respuesta",
"chat.messageBody.actions.moreActions": "Más acciones",
"chat.messageBody.toast.copied": "Copiado al portapapeles",
"chat.messageBody.actions.savingImage": "Guardando imagen...",
"chat.messageBody.actions.saveAsImage": "Guardar como imagen",
"chat.messageBody.actions.saveAsPlan": "Guardar como plan",
+8
View File
@@ -3,6 +3,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict = {
'commitComparison.mode': 'Commit',
'commitComparison.select': 'Choisir un commit',
'commitComparison.search': 'Rechercher des commits...',
'commitComparison.loadError': 'Impossible de charger les commits',
'commitComparison.noCommits': 'Aucun commit trouvé',
'commitComparison.emptyDiff': 'Aucune modification dans ce commit',
'chat.liveActivity.title': 'Activité',
'chat.liveActivity.changedFile': '{count} fichier modifié',
'chat.liveActivity.changedFiles': '{count} fichiers modifiés',
@@ -2037,6 +2043,8 @@ export const dict = {
'chat.messageBody.actions.openPreviewAria': 'Ouvrir l\'aperçu',
'chat.messageBody.actions.openPreview': 'Ouvrir l\'aperçu',
'chat.messageBody.actions.copyAnswer': 'Copier la réponse',
'chat.messageBody.actions.moreActions': 'Plus dactions',
'chat.messageBody.toast.copied': 'Copié dans le presse-papiers',
'chat.messageBody.actions.savingImage': 'Enregistrement de l\'image...',
'chat.messageBody.actions.saveAsImage': 'Enregistrer sous image',
'chat.messageBody.actions.saveAsPlan': 'Enregistrer comme forfait',
+8
View File
@@ -4,6 +4,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'commitComparison.mode': 'コミット',
'commitComparison.select': 'コミットを選択',
'commitComparison.search': 'コミットを検索...',
'commitComparison.loadError': 'コミットを読み込めませんでした',
'commitComparison.noCommits': 'コミットが見つかりません',
'commitComparison.emptyDiff': 'このコミットに変更はありません',
'chat.liveActivity.title': 'アクティビティ',
'chat.liveActivity.changedFile': '{count} ファイルを変更',
'chat.liveActivity.changedFiles': '{count} ファイルを変更',
@@ -2310,6 +2316,8 @@ export const dict: Record<I18nKey, string> = {
'chat.messageBody.actions.openPreviewAria': 'プレビューを開く',
'chat.messageBody.actions.openPreview': 'プレビューを開く',
'chat.messageBody.actions.copyAnswer': '回答をコピー',
'chat.messageBody.actions.moreActions': 'その他の操作',
'chat.messageBody.toast.copied': 'クリップボードにコピーしました',
'chat.messageBody.actions.savingImage': '画像を保存中...',
'chat.messageBody.actions.saveAsImage': '画像として保存',
'chat.messageBody.actions.saveAsPlan': '計画として保存',
+8
View File
@@ -4,6 +4,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'commitComparison.mode': '커밋',
'commitComparison.select': '커밋 선택',
'commitComparison.search': '커밋 검색...',
'commitComparison.loadError': '커밋을 불러오지 못했습니다',
'commitComparison.noCommits': '커밋을 찾을 수 없습니다',
'commitComparison.emptyDiff': '이 커밋에는 변경 사항이 없습니다',
'chat.liveActivity.title': '활동',
'chat.liveActivity.changedFile': '파일 {count}개 변경',
'chat.liveActivity.changedFiles': '파일 {count}개 변경',
@@ -2314,6 +2320,8 @@ export const dict: Record<I18nKey, string> = {
'chat.messageBody.actions.unpinContext': '컨텍스트에서 고정 해제(압축 후 유지되지 않음)',
'chat.messageBody.actions.contextPinFailed': '컨텍스트 고정을 업데이트하지 못했습니다',
'chat.messageBody.actions.copyAnswer': '답변 복사',
'chat.messageBody.actions.moreActions': '추가 작업',
'chat.messageBody.toast.copied': '클립보드에 복사됨',
'chat.messageBody.actions.savingImage': '이미지 저장 중…',
'chat.messageBody.actions.saveAsImage': '이미지로 저장',
'chat.messageBody.actions.saveAsPlan': '플랜으로 저장',
+8
View File
@@ -4,6 +4,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'commitComparison.mode': 'Commit',
'commitComparison.select': 'Wybierz commit',
'commitComparison.search': 'Szukaj commitów...',
'commitComparison.loadError': 'Nie udało się wczytać commitów',
'commitComparison.noCommits': 'Nie znaleziono commitów',
'commitComparison.emptyDiff': 'Brak zmian w tym commicie',
'chat.liveActivity.title': 'Aktywność',
'chat.liveActivity.changedFile': 'Zmieniono {count} plik',
'chat.liveActivity.changedFiles': 'Zmienione pliki: {count}',
@@ -963,6 +969,8 @@ export const dict: Record<I18nKey, string> = {
'chat.messageBody.actions.openPreviewAria': 'Otwórz podgląd',
'chat.messageBody.actions.openPreview': 'Otwórz podgląd',
'chat.messageBody.actions.copyAnswer': 'Kopiuj odpowiedź',
'chat.messageBody.actions.moreActions': 'Więcej akcji',
'chat.messageBody.toast.copied': 'Skopiowano do schowka',
'chat.messageBody.actions.savingImage': 'Zapisywanie obrazu...',
'chat.messageBody.actions.saveAsImage': 'Zapisz jako obraz',
'chat.messageBody.actions.saveAsPlan': 'Zapisz jako plan',
@@ -4,6 +4,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'commitComparison.mode': 'Commit',
'commitComparison.select': 'Selecionar commit',
'commitComparison.search': 'Buscar commits...',
'commitComparison.loadError': 'Não foi possível carregar os commits',
'commitComparison.noCommits': 'Nenhum commit encontrado',
'commitComparison.emptyDiff': 'Nenhuma alteração neste commit',
'chat.liveActivity.title': 'Atividade',
'chat.liveActivity.changedFile': '{count} arquivo alterado',
'chat.liveActivity.changedFiles': '{count} arquivos alterados',
@@ -2292,6 +2298,8 @@ export const dict: Record<I18nKey, string> = {
"chat.messageBody.actions.openPreviewAria": "Abrir visualização",
"chat.messageBody.actions.openPreview": "Abrir visualização",
"chat.messageBody.actions.copyAnswer": "Copiar resposta",
"chat.messageBody.actions.moreActions": "Mais ações",
"chat.messageBody.toast.copied": "Copiado para a área de transferência",
"chat.messageBody.actions.savingImage": "Salvando imagem...",
"chat.messageBody.actions.saveAsImage": "Salvar como imagem",
"chat.messageBody.actions.saveAsPlan": "Salvar como plano",
+8
View File
@@ -3,6 +3,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict = {
'commitComparison.mode': 'Commit',
'commitComparison.select': 'Commit seç',
'commitComparison.search': 'Commit ara...',
'commitComparison.loadError': 'Commitler yüklenemedi',
'commitComparison.noCommits': 'Commit bulunamadı',
'commitComparison.emptyDiff': 'Bu committe değişiklik yok',
'chat.liveActivity.title': 'Etkinlik',
'chat.liveActivity.changedFile': '{count} dosya değiştirildi',
'chat.liveActivity.changedFiles': '{count} dosya değiştirildi',
@@ -2251,6 +2257,8 @@ export const dict = {
'chat.messageBody.actions.openPreviewAria': 'Önizlemeyi aç',
'chat.messageBody.actions.openPreview': 'Önizlemeyi aç',
'chat.messageBody.actions.copyAnswer': 'Yanıtı kopyala',
'chat.messageBody.actions.moreActions': 'Diğer işlemler',
'chat.messageBody.toast.copied': 'Panoya kopyalandı',
'chat.messageBody.actions.savingImage': 'Görsel kaydediliyor...',
'chat.messageBody.actions.saveAsImage': 'Görsel olarak kaydet',
'chat.messageBody.actions.saveAsPlan': 'Plan olarak kaydet',
+8
View File
@@ -4,6 +4,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'commitComparison.mode': 'Коміт',
'commitComparison.select': 'Вибрати коміт',
'commitComparison.search': 'Пошук комітів...',
'commitComparison.loadError': 'Не вдалося завантажити коміти',
'commitComparison.noCommits': 'Комітів не знайдено',
'commitComparison.emptyDiff': 'У цьому коміті немає змін',
'chat.liveActivity.title': 'Дії',
'chat.liveActivity.changedFile': 'Змінено {count} файл',
'chat.liveActivity.changedFiles': 'Змінено файлів: {count}',
@@ -2292,6 +2298,8 @@ export const dict: Record<I18nKey, string> = {
"chat.messageBody.actions.openPreviewAria": "Відкрити попередній перегляд",
"chat.messageBody.actions.openPreview": "Відкрити попередній перегляд",
"chat.messageBody.actions.copyAnswer": "Скопіювати відповідь",
"chat.messageBody.actions.moreActions": "Більше дій",
"chat.messageBody.toast.copied": "Скопійовано в буфер обміну",
"chat.messageBody.actions.savingImage": "Збереження зображення...",
"chat.messageBody.actions.saveAsImage": "Зберегти як зображення",
"chat.messageBody.actions.saveAsPlan": "Зберегти як план",
@@ -4,6 +4,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'commitComparison.mode': '提交',
'commitComparison.select': '选择提交',
'commitComparison.search': '搜索提交...',
'commitComparison.loadError': '无法加载提交',
'commitComparison.noCommits': '未找到提交',
'commitComparison.emptyDiff': '此提交没有更改',
'chat.liveActivity.title': '活动',
'chat.liveActivity.changedFile': '更改了 {count} 个文件',
'chat.liveActivity.changedFiles': '更改了 {count} 个文件',
@@ -2280,6 +2286,8 @@ export const dict: Record<I18nKey, string> = {
'chat.messageBody.actions.openPreviewAria': '打开预览',
'chat.messageBody.actions.openPreview': '打开预览',
'chat.messageBody.actions.copyAnswer': '复制回答',
'chat.messageBody.actions.moreActions': '更多操作',
'chat.messageBody.toast.copied': '已复制到剪贴板',
'chat.messageBody.actions.savingImage': '正在保存图片...',
'chat.messageBody.actions.saveAsImage': '保存为图片',
'chat.messageBody.actions.saveAsPlan': '保存为计划',
@@ -4,6 +4,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'commitComparison.mode': '提交',
'commitComparison.select': '選擇提交',
'commitComparison.search': '搜尋提交...',
'commitComparison.loadError': '無法載入提交',
'commitComparison.noCommits': '找不到提交',
'commitComparison.emptyDiff': '此提交沒有變更',
'chat.liveActivity.title': '活動',
'chat.liveActivity.changedFile': '變更了 {count} 個檔案',
'chat.liveActivity.changedFiles': '變更了 {count} 個檔案',
@@ -2284,6 +2290,8 @@ export const dict: Record<I18nKey, string> = {
'chat.messageBody.actions.openPreviewAria': '開啟預覽',
'chat.messageBody.actions.openPreview': '開啟預覽',
'chat.messageBody.actions.copyAnswer': '複製回答',
'chat.messageBody.actions.moreActions': '更多操作',
'chat.messageBody.toast.copied': '已複製到剪貼簿',
'chat.messageBody.actions.savingImage': '正在儲存圖片...',
'chat.messageBody.actions.saveAsImage': '儲存為圖片',
'chat.messageBody.actions.saveAsPlan': '儲存為計畫',
+1
View File
@@ -11,6 +11,7 @@ export type WalkthroughWorkingTreeScope = 'all' | 'staged' | 'working';
export type WalkthroughSource =
| { kind: 'working-tree'; scope: WalkthroughWorkingTreeScope }
| { kind: 'branch'; baseRef: string; headRef: string }
| { kind: 'commit'; hash: string }
| { kind: 'pr'; number: number };
export type WalkthroughChapterIcon = 'bug' | 'wrench' | 'path' | 'flask' | 'doc' | 'gear';
+9
View File
@@ -42,6 +42,15 @@ refresh attempt per opening, so a failed first load cannot create a retry loop.
### UI state stores
`useCommitSelectionStore.ts` shares the selected commit between Changes and
walkthrough. Choices are session-only and keyed by runtime, directory, and
checked-out branch, with at most 100 remembered choices. The picker history
belongs to `useCommitComparison`, loads only while Commit mode is active, and
is limited to the latest 50 commits. History failure stays distinct from an
empty list; stale directory/runtime requests cannot replace current history or
selection. A refreshed list preserves an explicit selection even when newer
commits have pushed it beyond the latest 50.
Examples:
- `useUIStore.ts`
@@ -0,0 +1,20 @@
import { afterEach, expect, test } from 'bun:test';
import { commitSelectionKey, useCommitSelectionStore } from './useCommitSelectionStore';
afterEach(() => useCommitSelectionStore.setState({ selections: new Map() }));
test('shares a commit choice within its runtime, repository and checked-out branch only', () => {
const key = commitSelectionKey('/repo', 'feature', 'runtime-a');
const commit = {
hash: 'a'.repeat(40), message: 'Selected commit', date: '2026-09-09T09:22:00Z',
author_name: 'Test Author', author_email: 'test@example.com', refs: '', body: '',
filesChanged: 1, insertions: 1, deletions: 0, parents: [],
};
useCommitSelectionStore.getState().select(key, commit);
expect(useCommitSelectionStore.getState().selections.get(key)).toEqual(commit);
for (const otherKey of [
commitSelectionKey('/other', 'feature', 'runtime-a'),
commitSelectionKey('/repo', 'other', 'runtime-a'),
commitSelectionKey('/repo', 'feature', 'runtime-b'),
]) expect(useCommitSelectionStore.getState().selections.get(otherKey)).toBeUndefined();
});
@@ -0,0 +1,28 @@
import { create } from 'zustand';
import type { GitLogEntry } from '@/lib/api/types';
import { getRuntimeKey } from '@/lib/runtime-switch';
export const commitSelectionKey = (directory: string, branch: string | null, runtimeKey = getRuntimeKey()): string =>
JSON.stringify([runtimeKey, directory, branch]);
interface CommitSelectionState {
selections: Map<string, GitLogEntry>;
select: (key: string, commit: GitLogEntry) => void;
}
// Shared between Changes and walkthrough. Selections are session-only and the
// branch is part of the key, so a checkout starts with that branch's history.
export const useCommitSelectionStore = create<CommitSelectionState>((set) => ({
selections: new Map(),
select: (key, commit) => set((state) => {
const selections = new Map(state.selections);
selections.delete(key);
selections.set(key, commit);
// Bound remembered choices on explicit selection, never while acquiring a view.
if (selections.size > 100) {
const oldest = selections.keys().next().value;
if (oldest !== undefined) selections.delete(oldest);
}
return { selections };
}),
}));
@@ -13,6 +13,13 @@ beforeEach(() => {
});
describe('useUIStore context panel tabs', () => {
test('preserves Commit mode when context tabs are normalized', () => {
useUIStore.getState().openContextPanelTab('/repo', { mode: 'diff', diffScope: 'commit' });
useUIStore.getState().openContextPanelTab('/repo', { mode: 'file', targetPath: '/repo/README.md' });
const diffTab = getContextPanelTabs('/repo').find((tab) => tab.mode === 'diff');
expect(diffTab?.diffScope).toBe('commit');
});
test('updates readOnly when an existing chat tab is reopened', () => {
const directory = '/repo';
+2 -2
View File
@@ -15,7 +15,7 @@ import { isWindowsArm64 } from '@/lib/platform';
import { isVSCodeRuntime } from '@/lib/desktop';
import { getRuntimeKey, isTransientRuntimeKey } from '@/lib/runtime-switch';
export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch';
export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch' | 'commit';
export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'linear' | 'notes' | 'terminal';
export type MermaidRenderingMode = 'svg' | 'ascii';
export type UserMessageRenderingMode = 'markdown' | 'plain';
@@ -280,7 +280,7 @@ const normalizeContextTabLabel = (value: string | null | undefined): string | nu
};
const normalizePendingDiffScope = (value: unknown): PendingDiffScope | null => {
return value === 'working' || value === 'staged' || value === 'turn' || value === 'branch' ? value : null;
return value === 'working' || value === 'staged' || value === 'turn' || value === 'branch' || value === 'commit' ? value : null;
};
/** A plan tab's owner must be a complete project reference or nothing; a
@@ -168,6 +168,18 @@ describe('useWalkthroughStore — model selection', () => {
expect(useWalkthroughStore.getState().getSelectedModel('/repo', SOURCE))
.toBe('anthropic/claude-haiku-4-5');
});
test('keeps commit walkthroughs separate and selecting one does not generate', () => {
const generatedBefore = generateCalls;
const first: WalkthroughSource = { kind: 'commit', hash: 'a'.repeat(40) };
const second: WalkthroughSource = { kind: 'commit', hash: 'b'.repeat(40) };
useWalkthroughStore.getState().selectModel('/repo', first, 'anthropic/claude-haiku-4-5');
useWalkthroughStore.getState().requestSource('/repo', second);
expect(useWalkthroughStore.getState().getSelectedModel('/repo', first)).toBe('anthropic/claude-haiku-4-5');
expect(useWalkthroughStore.getState().getSelectedModel('/repo', second)).toBeUndefined();
expect(useWalkthroughStore.getState().requestedSource['/repo']).toEqual(second);
expect(generateCalls).toBe(generatedBefore);
});
});
describe('useWalkthroughStore — walkthrough language', () => {
Binary file not shown.