Skip to content

Commit 512912c

Browse files
authored
Merge pull request #593 from Mng-dev-ai/add-changed-files-panel
Add changed files panel under assistant messages
2 parents 1a16994 + c97cf55 commit 512912c

11 files changed

Lines changed: 329 additions & 3 deletions

File tree

backend/app/api/endpoints/chat.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
MessageEvent,
5050
PermissionRespondResponse,
5151
)
52-
from app.models.schemas.sandbox import GitCommandResponse
52+
from app.models.schemas.sandbox import ChangedFilesResponse, GitCommandResponse
5353
from app.models.schemas.pagination import (
5454
CursorPaginatedResponse,
5555
CursorPaginationParams,
@@ -439,6 +439,28 @@ async def restore_message_checkpoint(
439439
) from e
440440

441441

442+
@router.get(
443+
"/messages/{message_id}/changes",
444+
response_model=ChangedFilesResponse,
445+
)
446+
async def get_message_changes(
447+
message_id: UUID,
448+
current_user: User = Depends(get_current_user),
449+
chat_service: ChatService = Depends(get_chat_service),
450+
) -> ChangedFilesResponse:
451+
try:
452+
return await chat_service.get_changed_files(message_id, current_user)
453+
except ChatException as e:
454+
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
455+
except SandboxException as e:
456+
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
457+
except ValueError as e:
458+
raise HTTPException(
459+
status_code=status.HTTP_400_BAD_REQUEST,
460+
detail=str(e),
461+
) from e
462+
463+
442464
@router.delete("/chats/{chat_id}/stream", status_code=status.HTTP_204_NO_CONTENT)
443465
async def cancel_stream(
444466
chat_id: UUID,

backend/app/models/schemas/sandbox.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Literal
2+
13
from pydantic import BaseModel, Field
24

35

@@ -97,6 +99,21 @@ class GitRemoteUrlResponse(BaseModel):
9799
remote_url: str
98100

99101

102+
class ChangedFile(BaseModel):
103+
path: str
104+
status: Literal["M", "A", "D"]
105+
additions: int
106+
deletions: int
107+
108+
109+
class ChangedFilesResponse(BaseModel):
110+
files: list[ChangedFile]
111+
# Workspace-root-relative cwd the file paths are reported against — the
112+
# frontend joins it onto each path to navigate the editor (which keys on
113+
# workspace-root paths). Empty for non-worktree chats.
114+
cwd: str = ""
115+
116+
100117
class SearchMatch(BaseModel):
101118
line_number: int
102119
line_text: str

backend/app/services/chat.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
ChatUpdate,
2727
)
2828
from app.models.schemas.chat import Message as MessageSchema
29-
from app.models.schemas.sandbox import GitCommandResponse
29+
from app.models.schemas.sandbox import ChangedFilesResponse, GitCommandResponse
3030
from app.models.schemas.pagination import (
3131
CursorPaginatedResponse,
3232
PaginatedResponse,
@@ -687,6 +687,21 @@ async def restore_checkpoint_all(
687687
cwd=checkpoint.cwd,
688688
)
689689

690+
async def get_changed_files(
691+
self,
692+
message_id: UUID,
693+
user: User,
694+
) -> ChangedFilesResponse:
695+
checkpoint, chat = await self._get_checkpoint_target(message_id, user)
696+
git_service = GitService(self.sandbox_for_workspace(chat.workspace))
697+
files = await git_service.get_changed_files(
698+
chat.workspace.sandbox_id,
699+
base_head=checkpoint.base_head,
700+
pre_run_diff=checkpoint.pre_run_diff,
701+
cwd=checkpoint.cwd,
702+
)
703+
return ChangedFilesResponse(files=files, cwd=checkpoint.cwd or "")
704+
690705
async def _get_checkpoint_target(
691706
self,
692707
message_id: UUID,

backend/app/services/git.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from uuid import uuid4
77

88
from app.models.schemas.sandbox import (
9+
ChangedFile,
910
GitBranchesResponse,
1011
GitCheckoutResponse,
1112
GitCommandResponse,
@@ -75,6 +76,37 @@ class Checkpoint(NamedTuple):
7576
"git worktree add '$worktree_dir' -b '$branch_name' 2>&1"
7677
)
7778

79+
# Build two trees and diff them so the result reflects only the assistant's
80+
# turn. The base tree is base_head + pre_run_diff applied via a temp index
81+
# (otherwise pre-existing dirty changes captured by the checkpoint would be
82+
# attributed to the assistant). The current tree is the working tree captured
83+
# by copying the real index then `git add -A` into a temp index — this folds
84+
# untracked files into the comparison, so pre-existing untracked files stay
85+
# silent and assistant-created files surface as additions.
86+
# `--no-renames` collapses renames to add+delete so the parser stays simple.
87+
GIT_CHANGED_FILES_TEMPLATE = Template(
88+
"{ base_tree='$base'; "
89+
'if [ -n "$patch_file" ]; then '
90+
"tmp_b=$$(mktemp); "
91+
"if GIT_INDEX_FILE=\"$$tmp_b\" git read-tree '$base' 2>/dev/null "
92+
'&& GIT_INDEX_FILE="$$tmp_b" git apply --cached --whitespace=nowarn '
93+
"$patch_file 2>/dev/null; then "
94+
'base_tree=$$(GIT_INDEX_FILE="$$tmp_b" git write-tree); '
95+
"fi; "
96+
'rm -f "$$tmp_b" $patch_file; '
97+
"fi; "
98+
"tmp_c=$$(mktemp); "
99+
'cp "$$(git rev-parse --git-path index)" "$$tmp_c" 2>/dev/null '
100+
'|| GIT_INDEX_FILE="$$tmp_c" git read-tree HEAD 2>/dev/null; '
101+
'GIT_INDEX_FILE="$$tmp_c" git add -A 2>/dev/null; '
102+
'cur_tree=$$(GIT_INDEX_FILE="$$tmp_c" git write-tree 2>/dev/null); '
103+
'rm -f "$$tmp_c"; '
104+
'git diff --numstat --no-renames "$$base_tree" "$$cur_tree" 2>/dev/null; '
105+
"printf '__STATUS__\\n'; "
106+
'git diff --name-status --no-renames "$$base_tree" "$$cur_tree" 2>/dev/null; '
107+
"}"
108+
)
109+
78110
GIT_DIFF_STAGED_TEMPLATE = Template("git diff$ctx --cached 2>/dev/null")
79111
GIT_DIFF_UNSTAGED_TEMPLATE = Template("git diff$ctx 2>/dev/null;$untracked")
80112
# "all" mode: try `git diff HEAD` first (combined staged+unstaged in one pass);
@@ -428,6 +460,78 @@ async def restore_checkpoint_all(
428460
)
429461
return await self.run_command(sandbox_id, cmd, cwd)
430462

463+
async def get_changed_files(
464+
self,
465+
sandbox_id: str,
466+
base_head: str,
467+
pre_run_diff: str = "",
468+
cwd: str | None = None,
469+
) -> list[ChangedFile]:
470+
if not re.fullmatch(r"[0-9a-fA-F]{40}", base_head):
471+
raise ValueError("Invalid checkpoint commit")
472+
patch_file = ""
473+
if pre_run_diff:
474+
name = f".agentrove-changed-{uuid4().hex}.patch"
475+
patch_path = posixpath.join(cwd, name) if cwd else name
476+
await self.sandbox_service.provider.write_file(
477+
sandbox_id, patch_path, pre_run_diff
478+
)
479+
patch_file = shlex.quote(
480+
self.sandbox_service.provider.resolve_workspace_path(patch_path)
481+
)
482+
cd_prefix = git_cd_prefix(cwd)
483+
cmd = GIT_CHANGED_FILES_TEMPLATE.substitute(
484+
base=base_head, patch_file=patch_file
485+
)
486+
result = await self.sandbox_service.execute_command(
487+
sandbox_id, f"{cd_prefix}{cmd}"
488+
)
489+
if result.exit_code != 0:
490+
return []
491+
return self._parse_changed_files(result.stdout)
492+
493+
@staticmethod
494+
def _parse_changed_files(output: str) -> list[ChangedFile]:
495+
numstat_section, _, status_section = output.partition("__STATUS__\n")
496+
497+
# Binary files render as `-\t-\t<path>` in numstat — keep the path with
498+
# zeroed counts so the panel still lists the file.
499+
stats: dict[str, tuple[int, int]] = {}
500+
for line in numstat_section.splitlines():
501+
parts = line.split("\t")
502+
if len(parts) < 3:
503+
continue
504+
add_str, del_str, path = parts[0], parts[1], parts[2]
505+
additions = int(add_str) if add_str.isdigit() else 0
506+
deletions = int(del_str) if del_str.isdigit() else 0
507+
stats[path] = (additions, deletions)
508+
509+
statuses: dict[str, Literal["M", "A", "D"]] = {}
510+
for line in status_section.splitlines():
511+
parts = line.split("\t")
512+
if len(parts) < 2:
513+
continue
514+
code, path = parts[0], parts[1]
515+
letter = code[:1]
516+
if letter == "M":
517+
statuses[path] = "M"
518+
elif letter == "A":
519+
statuses[path] = "A"
520+
elif letter == "D":
521+
statuses[path] = "D"
522+
523+
files = [
524+
ChangedFile(
525+
path=path,
526+
status=statuses.get(path, "M"),
527+
additions=additions,
528+
deletions=deletions,
529+
)
530+
for path, (additions, deletions) in stats.items()
531+
]
532+
files.sort(key=lambda f: f.path)
533+
return files
534+
431535
async def create_branch(
432536
self,
433537
sandbox_id: str,
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { memo, useState } from 'react';
2+
import { ChevronDown, Files } from 'lucide-react';
3+
import { Button } from '@/components/ui/primitives/Button';
4+
import { useMessageChangesQuery } from '@/hooks/queries/useChatQueries';
5+
import { useUIStore } from '@/store/uiStore';
6+
import type { ChangedFile, ChangedFileStatus } from '@/types/sandbox.types';
7+
8+
interface ChangedFilesPanelProps {
9+
messageId: string;
10+
}
11+
12+
const STATUS_LABEL: Record<ChangedFileStatus, string> = {
13+
M: 'Modified',
14+
A: 'Added',
15+
D: 'Deleted',
16+
};
17+
18+
const STATUS_COLOR: Record<ChangedFileStatus, string> = {
19+
M: 'text-text-quaternary dark:text-text-dark-quaternary',
20+
A: 'text-success-600 dark:text-success-400',
21+
D: 'text-error-600 dark:text-error-400',
22+
};
23+
24+
const ChangedFilesPanelInner: React.FC<ChangedFilesPanelProps> = ({ messageId }) => {
25+
const [expanded, setExpanded] = useState(true);
26+
const { data } = useMessageChangesQuery(messageId);
27+
28+
const files = data?.files ?? [];
29+
const cwd = data?.cwd ?? '';
30+
if (files.length === 0) return null;
31+
32+
const { totalAdditions, totalDeletions } = files.reduce(
33+
(acc, f) => {
34+
acc.totalAdditions += f.additions;
35+
acc.totalDeletions += f.deletions;
36+
return acc;
37+
},
38+
{ totalAdditions: 0, totalDeletions: 0 },
39+
);
40+
41+
return (
42+
<div className="mt-3 overflow-hidden rounded-lg border border-border/50 bg-surface-secondary dark:border-border-dark/50 dark:bg-surface-dark-secondary">
43+
<Button
44+
type="button"
45+
variant="unstyled"
46+
onClick={() => setExpanded((prev) => !prev)}
47+
aria-expanded={expanded}
48+
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors duration-150 hover:bg-surface-hover dark:hover:bg-surface-dark-hover"
49+
>
50+
<Files className="h-3.5 w-3.5 flex-shrink-0 text-text-tertiary dark:text-text-dark-tertiary" />
51+
<span className="text-xs font-medium text-text-secondary dark:text-text-dark-secondary">
52+
{files.length} {files.length === 1 ? 'file' : 'files'} changed
53+
</span>
54+
<span className="text-text-quaternary dark:text-text-dark-quaternary">·</span>
55+
<span className="font-mono text-xs tabular-nums text-success-600 dark:text-success-400">
56+
+{totalAdditions}
57+
</span>
58+
<span className="font-mono text-xs tabular-nums text-error-600 dark:text-error-400">
59+
{totalDeletions}
60+
</span>
61+
<div className="flex-1" />
62+
<ChevronDown
63+
className={`h-3.5 w-3.5 flex-shrink-0 text-text-quaternary transition-transform duration-300 ease-out dark:text-text-dark-quaternary ${expanded ? 'rotate-180' : ''}`}
64+
/>
65+
</Button>
66+
67+
{expanded && (
68+
<div className="border-t border-border/50 dark:border-border-dark/50">
69+
{files.map((file, index) => (
70+
<ChangedFileRow
71+
key={file.path}
72+
file={file}
73+
cwd={cwd}
74+
isLast={index === files.length - 1}
75+
/>
76+
))}
77+
</div>
78+
)}
79+
</div>
80+
);
81+
};
82+
83+
const ChangedFileRow: React.FC<{ file: ChangedFile; cwd: string; isLast: boolean }> = ({
84+
file,
85+
cwd,
86+
isLast,
87+
}) => {
88+
const isDeleted = file.status === 'D';
89+
const editorPath = cwd ? `${cwd}/${file.path}` : file.path;
90+
const borderClass = isLast ? '' : 'border-b border-border/50 dark:border-border-dark/50';
91+
const interactiveClass = isDeleted
92+
? 'cursor-default'
93+
: 'transition-colors duration-150 hover:bg-surface-hover dark:hover:bg-surface-dark-hover';
94+
return (
95+
<Button
96+
type="button"
97+
variant="unstyled"
98+
disabled={isDeleted}
99+
onClick={isDeleted ? undefined : () => useUIStore.getState().openFileInEditor(editorPath)}
100+
className={`flex w-full items-center gap-2.5 px-3 py-2 text-left ${interactiveClass} ${borderClass}`}
101+
>
102+
<span
103+
className={`w-3.5 flex-shrink-0 text-center font-mono text-2xs ${STATUS_COLOR[file.status]}`}
104+
title={STATUS_LABEL[file.status]}
105+
>
106+
{file.status}
107+
</span>
108+
<span
109+
className="min-w-0 flex-1 truncate font-mono text-xs text-text-secondary dark:text-text-dark-secondary"
110+
title={file.path}
111+
>
112+
{file.path}
113+
</span>
114+
<span className="min-w-[28px] flex-shrink-0 text-right font-mono text-2xs tabular-nums text-success-600 dark:text-success-400">
115+
+{file.additions}
116+
</span>
117+
<span className="min-w-[24px] flex-shrink-0 text-right font-mono text-2xs tabular-nums text-error-600 dark:text-error-400">
118+
{file.deletions}
119+
</span>
120+
</Button>
121+
);
122+
};
123+
124+
export const ChangedFilesPanel = memo(ChangedFilesPanelInner);

frontend/src/components/chat/message-bubble/Message.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { memo, useMemo, useState } from 'react';
22
import { Undo2 } from 'lucide-react';
33
import { UserMessageContent, AssistantMessageContent } from './MessageContent';
44
import { MessageActions } from './MessageActions';
5+
import { ChangedFilesPanel } from './ChangedFilesPanel';
56
import { useModelMap } from '@/hooks/queries/useModelQueries';
67
import {
78
getAgentKindForModelId,
@@ -131,6 +132,8 @@ export const AssistantMessage = memo(function AssistantMessage({
131132
/>
132133
</div>
133134

135+
{checkpointId && !isStreaming && <ChangedFilesPanel messageId={id} />}
136+
134137
{showFooter && (
135138
<div className="mt-2 flex items-center justify-between">
136139
<div className="flex items-center gap-0.5">

frontend/src/hooks/queries/queryKeys.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const queryKeys = {
77
chat: (chatId?: string) => ['chat', chatId] as const,
88
messages: (chatId?: string) => ['messages', chatId] as const,
99
contextUsage: (chatId?: string) => ['chat', chatId, 'context-usage'] as const,
10+
messageChanges: (messageId?: string) => ['message', messageId, 'changes'] as const,
1011
subThreads: (chatId?: string) => ['chat', chatId, 'sub-threads'] as const,
1112
auth: {
1213
user: 'auth-user',

frontend/src/hooks/queries/useChatQueries.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
import { chatService } from '@/services/chatService';
1515
import { useMessageQueueStore } from '@/store/messageQueueStore';
1616
import type { Chat, ChatSearchResponse, ContextUsage, CreateChatRequest } from '@/types/chat.types';
17+
import type { ChangedFilesData } from '@/types/sandbox.types';
1718
import type { PaginatedChats } from '@/types/api.types';
1819
import { createMutation } from './createMutation';
1920
import { queryKeys } from './queryKeys';
@@ -123,6 +124,19 @@ export const useContextUsageQuery = (
123124
});
124125
};
125126

127+
export const useMessageChangesQuery = (
128+
messageId: string | undefined,
129+
options?: Partial<UseQueryOptions<ChangedFilesData>>,
130+
) => {
131+
return useQuery({
132+
queryKey: queryKeys.messageChanges(messageId),
133+
queryFn: () => chatService.getMessageChanges(messageId!),
134+
enabled: !!messageId,
135+
staleTime: Infinity,
136+
...options,
137+
});
138+
};
139+
126140
export const useCreateChatMutation = createMutation<Chat, Error, CreateChatRequest>(
127141
(data) => chatService.createChat(data),
128142
async (queryClient, newChat) => {

0 commit comments

Comments
 (0)