Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 97 additions & 34 deletions crates/agent-gateway/web/src/app/GatewayApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,14 @@ function isLocalDraftConversationId(id: string) {
return id.trim().startsWith(LOCAL_DRAFT_PREFIX);
}

import {
type ChangedFilesActions,
ChangedFilesActionsProvider,
} from "@/components/chat/ChangedFilesCard";
import { HistoryShareModal } from "@/components/chat/HistoryShareModal";
import { GatewayTranscript, type GatewayTranscriptNavHandle } from "@/components/GatewayTranscript";
import type { GitReviewFocusRequest } from "@/components/project-tools/RightDockContext";
import { expandedPathsForFileTreePath } from "@/components/project-tools/rightDockModel";
import { buildFloorEntries } from "@/lib/chat-floor-nav/floorModel";
import { useScrollFollow } from "@/lib/chat-scroll/useScrollFollow";
import { parseHistoryShareToken } from "@/lib/historyShare";
Expand Down Expand Up @@ -3658,6 +3664,59 @@ export default function GatewayApp() {
() => (api ? createGatewayWorkspaceActivityClient(api) : null),
[api],
);
// ── 回复末尾「已编辑文件」卡的三个动作 ────────────────────────────────
const gitReviewFocusNonceRef = useRef(0);
const [gitReviewFocusRequest, setGitReviewFocusRequest] = useState<GitReviewFocusRequest | null>(
null,
);
const handleGitReviewFocusRequestHandled = useCallback((nonce: number) => {
setGitReviewFocusRequest((current) => (current && current.nonce === nonce ? null : current));
}, []);
const handleChangedFileOpenDiff = useCallback(
(path: string | null) => {
if (!terminalProjectPathKey) return;
setRightDockOpen(true);
setSettings((prev) => openRightDockSingletonTab(prev, terminalProjectPathKey, "gitReview"));
gitReviewFocusNonceRef.current += 1;
setGitReviewFocusRequest({
path: (path ?? "").trim(),
nonce: gitReviewFocusNonceRef.current,
});
},
[setSettings, terminalProjectPathKey],
);
const handleChangedFileReveal = useCallback(
(path: string) => {
if (!terminalProjectPathKey) return;
const selectedPath = path
.trim()
.replace(/\\/g, "/")
.replace(/^\/+|\/+$/g, "");
if (!selectedPath) return;
setRightDockOpen(true);
setSettings((prev) => {
const opened = openRightDockSingletonTab(prev, terminalProjectPathKey, "fileTree");
const current = getRightDockFileTreeState(opened.customSettings, terminalProjectPathKey);
return updateRightDockFileTreeState(opened, terminalProjectPathKey, {
query: "",
selectedPath,
expandedPaths: Array.from(
new Set([...current.expandedPaths, ...expandedPathsForFileTreePath(selectedPath)]),
),
bumpRevision: true,
});
});
},
[setSettings, terminalProjectPathKey],
);
const changedFilesActions = useMemo<ChangedFilesActions>(
() => ({
onOpenFile: handleOpenWorkspaceFile,
onRevealInFileTree: handleChangedFileReveal,
onOpenDiff: handleChangedFileOpenDiff,
}),
[handleChangedFileOpenDiff, handleChangedFileReveal, handleOpenWorkspaceFile],
);
// RightDockPanel is memo'd: every callback handed to it must be stable or
// the memo boundary is void (see the panel-side context useMemo).
const handleRightDockWidthChange = useCallback(
Expand Down Expand Up @@ -4207,40 +4266,42 @@ export default function GatewayApp() {
viewportRef={setTranscriptViewport}
className="gateway-transcript-scroll"
>
<GatewayTranscript
conversationId={displayedConversationId}
rows={transcriptRows}
liveStartIndex={transcriptLiveStartIndex}
activeTurnKey={displayedTranscript.activeTurnKey}
isViewportFollowing={transcriptFollow.isFollowing}
navRef={transcriptNavRef}
onAnchorUserRowChange={setActiveFloorKey}
error={transcriptError}
toolStatus={transcriptToolStatus}
toolStatusIsCompaction={transcriptToolStatusIsCompaction}
retryAttempts={displayedTranscript.retryAttempts}
isStreaming={transcriptBusy}
isLoading={transcriptHistoryLoading}
loadingTitle={historyDetailLoadingTitle}
hasModels={modelOptions.length > 0}
onOpenSettings={openSettings}
hasMoreHistory={selectedHistoryHasMore}
isLoadingMoreHistory={loadingOlderHistory}
onLoadFullHistory={
selectedHistoryHasMore ? handleLoadFullHistory : undefined
}
isAgentMode={isAgentMode}
showUsage={isAgentDevExecutionMode}
usageContextWindow={currentModelContextWindow}
workspaceRoot={displayedConversationWorkdir}
gitClient={gitClient}
onLoadUploadedImagePreview={handleLoadUploadedImagePreview}
onResendFromEdit={handleResendFromEdit}
onBranchConversation={handleBranchConversation}
branchPendingMessageId={branchPendingMessageId}
onSuggestionSelect={handleEmptyStateSuggestion}
suggestionsDisabled={isSuggestionTyping}
/>
<ChangedFilesActionsProvider value={changedFilesActions}>
<GatewayTranscript
conversationId={displayedConversationId}
rows={transcriptRows}
liveStartIndex={transcriptLiveStartIndex}
activeTurnKey={displayedTranscript.activeTurnKey}
isViewportFollowing={transcriptFollow.isFollowing}
navRef={transcriptNavRef}
onAnchorUserRowChange={setActiveFloorKey}
error={transcriptError}
toolStatus={transcriptToolStatus}
toolStatusIsCompaction={transcriptToolStatusIsCompaction}
retryAttempts={displayedTranscript.retryAttempts}
isStreaming={transcriptBusy}
isLoading={transcriptHistoryLoading}
loadingTitle={historyDetailLoadingTitle}
hasModels={modelOptions.length > 0}
onOpenSettings={openSettings}
hasMoreHistory={selectedHistoryHasMore}
isLoadingMoreHistory={loadingOlderHistory}
onLoadFullHistory={
selectedHistoryHasMore ? handleLoadFullHistory : undefined
}
isAgentMode={isAgentMode}
showUsage={isAgentDevExecutionMode}
usageContextWindow={currentModelContextWindow}
workspaceRoot={displayedConversationWorkdir}
gitClient={gitClient}
onLoadUploadedImagePreview={handleLoadUploadedImagePreview}
onResendFromEdit={handleResendFromEdit}
onBranchConversation={handleBranchConversation}
branchPendingMessageId={branchPendingMessageId}
onSuggestionSelect={handleEmptyStateSuggestion}
suggestionsDisabled={isSuggestionTyping}
/>
</ChangedFilesActionsProvider>
</ScrollArea>
{displayedTranscriptRowCount > 0 && !conversationOpenState.showOverlay ? (
<FloorNavRail
Expand Down Expand Up @@ -4470,6 +4531,8 @@ export default function GatewayApp() {
onSessionsChange={handleProjectTerminalSessionsChange}
onInsertFileMention={handleRightDockInsertFileMention}
onOpenFile={handleOpenWorkspaceFile}
gitReviewFocusRequest={gitReviewFocusRequest}
onGitReviewFocusRequestHandled={handleGitReviewFocusRequestHandled}
onInsertCodeReviewSkill={
codeReviewSkill ? handleRightDockInsertCodeReviewSkill : undefined
}
Expand Down
155 changes: 155 additions & 0 deletions crates/agent-gateway/web/src/components/chat/ChangedFilesCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Reply-footer changed-files card: lists every file the assistant reply
// wrote/edited/deleted with per-file +N/-N stats, and wires the three
// file-reference actions (open editor / reveal in file tree / view diff).
// Rendered only after the reply settles (never mid-stream). Actions arrive
// through context so transcript row props stay memo-stable; without a
// provider (shared read-only views) the card renders as plain data.
import { createContext, memo, useContext, useMemo } from "react";
import { useLocale } from "../../i18n";
import type { ChangedFileEntry, ChangedFilesSummary } from "../../lib/chat/changedFiles";
import { cn } from "../../lib/shared/utils";
import { FilePenLine, FolderTree, GitCommitHorizontal } from "../icons";
import { FileChangeBadge } from "./FileChangeBadge";
import { getFileTypeIcon } from "./fileTypeIcons";

export type ChangedFilesActions = {
onOpenFile?: (path: string) => void;
onRevealInFileTree?: (path: string) => void;
/** null = open the review panel without focusing a specific file. */
onOpenDiff?: (path: string | null) => void;
};

const ChangedFilesActionsContext = createContext<ChangedFilesActions | null>(null);

export const ChangedFilesActionsProvider = ChangedFilesActionsContext.Provider;

export function useChangedFilesActions(): ChangedFilesActions | null {
return useContext(ChangedFilesActionsContext);
}

function splitPath(path: string): { dir: string; base: string } {
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
const index = normalized.lastIndexOf("/");
if (index < 0) return { dir: "", base: normalized };
return { dir: normalized.slice(0, index + 1), base: normalized.slice(index + 1) };
}

const ROW_ACTION_CLASS =
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-all hover:bg-foreground/[0.07] hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none group-hover/changed-file:opacity-100";

const ChangedFileRow = memo(function ChangedFileRow({ file }: { file: ChangedFileEntry }) {
const { t } = useLocale();
const actions = useChangedFilesActions();
const { dir, base } = splitPath(file.path);
const canOpen = Boolean(actions?.onOpenFile) && !file.deleted;
const FileTypeIcon = getFileTypeIcon(file.path, "file");

const pathLabel = (
<span className="flex min-w-0 flex-1 items-baseline font-mono text-[calc(11.5px*var(--zone-font-scale,1))] leading-[1.6]">
{dir ? <span className="truncate text-muted-foreground/70">{dir}</span> : null}
<span
className={cn(
"shrink-0 text-foreground/90",
file.deleted && "text-muted-foreground line-through",
)}
>
{base}
</span>
</span>
);

return (
<div className="group/changed-file flex min-w-0 items-center gap-1.5 rounded-lg px-2 py-1 transition-colors hover:bg-foreground/[0.04]">
<FileTypeIcon
className={cn("h-3.5 w-3.5 shrink-0", file.deleted && "opacity-50 saturate-0")}
/>
{canOpen ? (
<button
type="button"
onClick={() => actions?.onOpenFile?.(file.path)}
title={t("chat.changedFiles.open")}
className="flex min-w-0 flex-1 items-center text-left focus-visible:outline-none"
>
{pathLabel}
</button>
) : (
<span className="flex min-w-0 flex-1 items-center">{pathLabel}</span>
)}
{file.deleted ? (
<span className="shrink-0 rounded-full bg-muted/70 px-1.5 py-0.5 text-[calc(10px*var(--zone-font-scale,1))] leading-none text-muted-foreground">
{t("chat.changedFiles.deleted")}
</span>
) : (
<FileChangeBadge added={file.added} removed={file.removed} />
)}
{actions?.onRevealInFileTree ? (
<button
type="button"
onClick={() => actions.onRevealInFileTree?.(file.path)}
title={t("chat.changedFiles.reveal")}
aria-label={t("chat.changedFiles.reveal")}
className={ROW_ACTION_CLASS}
>
<FolderTree className="h-3.5 w-3.5" />
</button>
) : null}
{actions?.onOpenDiff ? (
<button
type="button"
onClick={() => actions.onOpenDiff?.(file.path)}
title={t("chat.changedFiles.diff")}
aria-label={t("chat.changedFiles.diff")}
className={ROW_ACTION_CLASS}
>
<GitCommitHorizontal className="h-3.5 w-3.5" />
</button>
) : null}
</div>
);
});

export const ChangedFilesCard = memo(function ChangedFilesCard({
summary,
}: {
summary: ChangedFilesSummary;
}) {
const { t } = useLocale();
const actions = useChangedFilesActions();
const title = useMemo(() => {
const key =
summary.files.length === 1 ? "chat.changedFiles.titleOne" : "chat.changedFiles.title";
return t(key).replace("{count}", String(summary.files.length));
}, [summary.files.length, t]);

return (
<div className="changed-files-card overflow-hidden rounded-xl border border-border/45 bg-background/60 backdrop-blur-sm dark:border-white/[0.07] dark:bg-white/[0.03]">
<div className="flex items-center gap-2.5 px-2.5 py-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border/45 bg-background/75 text-foreground/70 shadow-[0_1px_0_rgba(255,255,255,0.5)_inset] dark:border-white/[0.08] dark:bg-white/[0.05] dark:shadow-[0_1px_0_rgba(255,255,255,0.05)_inset]">
<FilePenLine className="h-4 w-4" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-[calc(12px*var(--zone-font-scale,1))] font-medium leading-tight text-foreground/85">
{title}
</span>
<FileChangeBadge added={summary.totalAdded} removed={summary.totalRemoved} />
</div>
{actions?.onOpenDiff ? (
<button
type="button"
onClick={() => actions.onOpenDiff?.(null)}
className="flex shrink-0 items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[calc(11px*var(--zone-font-scale,1))] font-medium leading-none text-muted-foreground transition-colors hover:bg-foreground/[0.06] hover:text-foreground focus-visible:bg-foreground/[0.06] focus-visible:outline-none"
>
<GitCommitHorizontal className="h-3.5 w-3.5" />
{t("chat.changedFiles.review")}
</button>
) : null}
</div>
{/* 最多露出 5 行,更多文件走内部滚动条。 */}
<div className="flex max-h-[calc(150px*var(--zone-font-scale,1))] flex-col gap-0.5 overflow-y-auto overscroll-contain border-t border-border/35 px-1 py-1 dark:border-white/[0.05]">
{summary.files.map((file) => (
<ChangedFileRow key={file.lastToolCallId || file.path} file={file} />
))}
</div>
</div>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,22 @@ export type RightDockFileTreeContext = {
onRevealInFileTree: (path: string) => void;
};

// One-shot focus request from the chat layer (reply-footer changed-files
// card): switch GitReview to the changes view and select `path`'s diff.
// Consumers must call onFocusRequestHandled(nonce) after applying so the
// request never replays on a later panel remount.
export type GitReviewFocusRequest = {
/** Empty string = just open the changes view without picking a file. */
path: string;
nonce: number;
};

export type RightDockGitContext = {
onInsertCodeReviewSkill?: () => void;
onInsertCommitMention?: (commit: GitCommitContextPayload) => void;
onInsertGitFileMention?: (file: GitFileContextPayload) => void;
focusRequest?: GitReviewFocusRequest | null;
onFocusRequestHandled?: (nonce: number) => void;
};

export type RightDockSshContext = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ import { Button } from "../ui/button";
import type { GitCommitContextPayload, GitFileContextPayload } from "./git-review";
import type { LocalTunnelClient } from "./LocalTunnelPanel";
import { RightDockContent } from "./RightDockContent";
import { RightDockToolContext, type RightDockToolContextValue } from "./RightDockContext";
import {
type GitReviewFocusRequest,
RightDockToolContext,
type RightDockToolContextValue,
} from "./RightDockContext";
import { RightDockChooser, RightDockCreateMenu } from "./RightDockLauncher";
import { RightDockTabStrip } from "./RightDockTabStrip";
import {
Expand Down Expand Up @@ -83,6 +87,8 @@ type RightDockPanelProps = {
onInsertCodeReviewSkill?: () => void;
onInsertCommitMention?: (commit: GitCommitContextPayload) => void;
onInsertGitFileMention?: (file: GitFileContextPayload) => void;
gitReviewFocusRequest?: GitReviewFocusRequest | null;
onGitReviewFocusRequestHandled?: (nonce: number) => void;
onClose?: () => void;
};

Expand Down Expand Up @@ -360,6 +366,8 @@ export const RightDockPanel = memo(function RightDockPanel(props: RightDockPanel
onInsertCodeReviewSkill,
onInsertCommitMention,
onInsertGitFileMention,
gitReviewFocusRequest,
onGitReviewFocusRequestHandled,
onClose,
} = props;
const { t } = useLocale();
Expand Down Expand Up @@ -659,6 +667,8 @@ export const RightDockPanel = memo(function RightDockPanel(props: RightDockPanel
onInsertCodeReviewSkill,
onInsertCommitMention,
onInsertGitFileMention,
focusRequest: gitReviewFocusRequest,
onFocusRequestHandled: onGitReviewFocusRequestHandled,
},
ssh: {
hosts: sshHosts,
Expand All @@ -682,8 +692,10 @@ export const RightDockPanel = memo(function RightDockPanel(props: RightDockPanel
forgetTerminalSession,
gitClient,
gitDisabledMessage,
gitReviewFocusRequest,
gitWriteEnabled,
onFileTreeStateChange,
onGitReviewFocusRequestHandled,
onInsertCodeReviewSkill,
onInsertCommitMention,
onInsertFileMention,
Expand Down
Loading
Loading