Skip to content

Commit 9484ba1

Browse files
committed
Merge branch 'main' into signadou/release-ci
2 parents a2bb240 + 94a2acd commit 9484ba1

File tree

16 files changed

+545
-177
lines changed

16 files changed

+545
-177
lines changed

crates/backend/src/session/mod.rs

Lines changed: 198 additions & 104 deletions
Large diffs are not rendered by default.

frontend/src/components/common/DiffViewer.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useState } from "react";
22
import { ChevronDown, ChevronRight } from "lucide-react";
3+
import { useTranslation } from "react-i18next";
34
import { cn } from "@/lib/utils";
45
import {
56
createLineDiffWithWords,
@@ -24,6 +25,7 @@ export function DiffViewer({
2425
className,
2526
onStatsCalculated,
2627
}: DiffViewerProps) {
28+
const { t } = useTranslation();
2729
const [isExpanded, setIsExpanded] = useState(false);
2830

2931
// Use enhanced word-level diff algorithm
@@ -163,8 +165,8 @@ export function DiffViewer({
163165
)}
164166
<span className="text-xs text-muted-foreground">
165167
{isExpanded
166-
? "Show less"
167-
: `Show ${diffLines.length - maxLines} more lines`}
168+
? t("common.showLess")
169+
: t("common.showMoreLines", { count: diffLines.length - maxLines })}
168170
</span>
169171
</div>
170172
)}

frontend/src/components/common/FilePickerDropdown.tsx

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from "react";
1+
import React, { useRef, useEffect } from "react";
22
import { DirEntry } from "@/lib/webApi";
33
import { cn } from "@/lib/utils";
44
import { Folder, File, Loader2, AlertCircle } from "lucide-react";
@@ -24,6 +24,18 @@ export const FilePickerDropdown: React.FC<FilePickerDropdownProps> = ({
2424
searchFilter,
2525
className,
2626
}) => {
27+
const selectedItemRef = useRef<HTMLDivElement>(null);
28+
29+
// Scroll selected item into view when selection changes
30+
useEffect(() => {
31+
if (selectedItemRef.current) {
32+
selectedItemRef.current.scrollIntoView({
33+
behavior: 'smooth',
34+
block: 'nearest',
35+
inline: 'nearest'
36+
});
37+
}
38+
}, [selectedIndex]);
2739
const formatEntryName = (entry: DirEntry): string => {
2840
return entry.is_directory ? `${entry.name}/` : entry.name;
2941
};
@@ -36,10 +48,24 @@ export const FilePickerDropdown: React.FC<FilePickerDropdownProps> = ({
3648
);
3749
};
3850

39-
const highlightSearchText = (text: string, searchFilter?: string) => {
51+
const highlightSearchText = (text: string, searchFilter?: string, isDirectory: boolean = false) => {
4052
if (!searchFilter) return text;
4153

42-
const regex = new RegExp(`(${searchFilter})`, 'gi');
54+
// Special case: if user has typed the folder name with trailing slash,
55+
// and this is a directory, highlight the entire name including slash
56+
if (isDirectory && text.endsWith('/') && searchFilter.endsWith('/') &&
57+
text.toLowerCase() === searchFilter.toLowerCase()) {
58+
return (
59+
<span className="bg-yellow-200 dark:bg-yellow-800 font-semibold">
60+
{text}
61+
</span>
62+
);
63+
}
64+
65+
// For all other cases, use regex to highlight only the matching parts
66+
// Escape special regex characters in searchFilter to avoid issues with slashes
67+
const escapedSearchFilter = searchFilter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
68+
const regex = new RegExp(`(${escapedSearchFilter})`, 'gi');
4369
const parts = text.split(regex);
4470

4571
return parts.map((part, index) =>
@@ -85,22 +111,20 @@ export const FilePickerDropdown: React.FC<FilePickerDropdownProps> = ({
85111

86112
return (
87113
<div className={cn("absolute bottom-full left-0 w-full mb-1 bg-white dark:bg-neutral-800 border border-gray-200 dark:border-neutral-700 rounded-md shadow-lg z-50 py-2 max-h-60 overflow-y-auto", className)}>
88-
{/* Current path header */}
89-
<div className="px-3 py-2 border-b border-gray-200 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-700">
90-
<div className="text-xs text-gray-600 dark:text-gray-300 font-mono truncate" title={currentPath}>
91-
{currentPath}
92-
</div>
93-
{searchFilter && (
94-
<div className="text-xs text-blue-600 dark:text-blue-400 mt-1">
114+
{/* Search filter header (only show if filtering) */}
115+
{searchFilter && (
116+
<div className="px-3 py-2 border-b border-gray-200 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-700">
117+
<div className="text-xs text-blue-600 dark:text-blue-400">
95118
Filtering: "{searchFilter}"
96119
</div>
97-
)}
98-
</div>
120+
</div>
121+
)}
99122

100123
{/* File and folder entries */}
101124
{entries.map((entry, index) => (
102125
<div
103126
key={`${entry.name}-${entry.is_directory}`}
127+
ref={index === selectedIndex ? selectedItemRef : null}
104128
className={cn(
105129
"flex items-center gap-2 px-3 py-2 cursor-pointer text-sm transition-colors",
106130
index === selectedIndex
@@ -113,7 +137,7 @@ export const FilePickerDropdown: React.FC<FilePickerDropdownProps> = ({
113137
>
114138
{getEntryIcon(entry)}
115139
<span className="font-mono text-sm flex-1 truncate">
116-
{highlightSearchText(formatEntryName(entry), searchFilter)}
140+
{highlightSearchText(formatEntryName(entry), searchFilter, entry.is_directory)}
117141
</span>
118142

119143
{/* File size for files */}
@@ -128,7 +152,7 @@ export const FilePickerDropdown: React.FC<FilePickerDropdownProps> = ({
128152
{/* Navigation hint */}
129153
<div className="px-3 py-2 border-t border-gray-200 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-700">
130154
<div className="text-xs text-gray-500 dark:text-gray-400">
131-
<span className="font-semibold">Enter:</span> Select • <span className="font-semibold">Tab:</span> Select & enter folder <span className="font-semibold">↑↓:</span> Move • <span className="font-semibold">Esc:</span> Close
155+
<span className="font-semibold">Enter:</span> Select • <span className="font-semibold">Tab:</span> Smart select/navigate<span className="font-semibold">↑↓:</span> Move • <span className="font-semibold">Esc:</span> Close
132156
</div>
133157
</div>
134158
</div>

frontend/src/components/common/MentionInput.tsx

Lines changed: 125 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,40 @@ export function MentionInput({
6161
if (/\s/.test(charBeforeAt) || atIndex === 0) {
6262
// Only show if there's no space after @ (still building the mention)
6363
if (!afterAt.includes(" ")) {
64-
return { atIndex, searchText: afterAt };
64+
console.log("🔍 [detectAtTrigger] Found @ trigger - afterAt:", JSON.stringify(afterAt));
65+
66+
// Parse the path to find directory and search filter
67+
const lastSlashIndex = afterAt.lastIndexOf('/');
68+
if (lastSlashIndex !== -1) {
69+
const directory = afterAt.substring(0, lastSlashIndex + 1); // Include the trailing slash
70+
const searchFilter = afterAt.substring(lastSlashIndex + 1);
71+
72+
// Special case: if search filter is empty and we end with slash,
73+
// we're actually trying to complete the folder name, not search inside it
74+
if (searchFilter === "") {
75+
// Remove the trailing slash and treat the last part as search filter INCLUDING the slash
76+
const folderPath = afterAt.substring(0, lastSlashIndex);
77+
const parentSlashIndex = folderPath.lastIndexOf('/');
78+
79+
if (parentSlashIndex !== -1) {
80+
const parentDirectory = folderPath.substring(0, parentSlashIndex + 1);
81+
const folderName = folderPath.substring(parentSlashIndex + 1);
82+
console.log("🔍 [detectAtTrigger] Folder completion - parentDirectory:", JSON.stringify(parentDirectory), "folderName:", JSON.stringify(folderName + "/"));
83+
return { atIndex, searchText: afterAt, directory: parentDirectory, searchFilter: folderName + "/" };
84+
} else {
85+
// No parent directory, completing folder in root
86+
console.log("🔍 [detectAtTrigger] Root folder completion - folderName:", JSON.stringify(folderPath + "/"));
87+
return { atIndex, searchText: afterAt, directory: "", searchFilter: folderPath + "/" };
88+
}
89+
} else {
90+
console.log("🔍 [detectAtTrigger] File search - directory:", JSON.stringify(directory), "searchFilter:", JSON.stringify(searchFilter));
91+
return { atIndex, searchText: afterAt, directory, searchFilter };
92+
}
93+
} else {
94+
// No slash, so it's just a search in the current directory
95+
console.log("🔍 [detectAtTrigger] No slash found, treating as root search:", JSON.stringify(afterAt));
96+
return { atIndex, searchText: afterAt, directory: "", searchFilter: afterAt };
97+
}
6598
}
6699
}
67100
}
@@ -74,9 +107,15 @@ export function MentionInput({
74107
return navState.entries;
75108
}
76109

77-
return navState.entries.filter(entry =>
78-
entry.name.toLowerCase().includes(searchFilter.toLowerCase())
79-
);
110+
return navState.entries.filter(entry => {
111+
const entryDisplayName = entry.is_directory ? `${entry.name}/` : entry.name;
112+
const searchLower = searchFilter.toLowerCase();
113+
const entryLower = entry.name.toLowerCase();
114+
const entryDisplayLower = entryDisplayName.toLowerCase();
115+
116+
// Match against both the raw name and the display name (with slash for directories)
117+
return entryLower.includes(searchLower) || entryDisplayLower.includes(searchLower);
118+
});
80119
}, [navState.entries, searchFilter]);
81120

82121
// Reset selection when search filter changes or entries change
@@ -109,9 +148,22 @@ export function MentionInput({
109148
navActions.loadDirectory(workingDirectory);
110149
}
111150

151+
// If we have a directory path, navigate to that directory
152+
if (atTrigger.directory && atTrigger.directory !== "") {
153+
const targetDir = workingDirectory + "/" + atTrigger.directory.replace(/\/$/, ""); // Remove trailing slash
154+
console.log("🔍 [MentionInput] Directory path detected, navigating to:", targetDir);
155+
if (navState.currentPath !== targetDir) {
156+
navActions.loadDirectory(targetDir);
157+
}
158+
// Set the search filter to just the filename part
159+
setSearchFilter(atTrigger.searchFilter || "");
160+
} else {
161+
// No directory, just search in current directory
162+
setSearchFilter(atTrigger.searchFilter || atTrigger.searchText);
163+
}
164+
112165
console.log("🔥 [MentionInput] Setting @ trigger state");
113166
setAtPosition(atTrigger.atIndex);
114-
setSearchFilter(atTrigger.searchText);
115167
setShowFilePicker(true);
116168
setFilteredSelectedIndex(0);
117169
} else {
@@ -153,11 +205,46 @@ export function MentionInput({
153205

154206
const mentionText = entry.is_directory ? `${entry.name}/` : entry.name;
155207

156-
// If this is the first mention (Tab from initial directory), just use the entry name
157-
// If continuing a path, append to the existing mention
158-
const newMentionText = existingMentionText.length === 0 ? mentionText : `${existingMentionText}${mentionText}`;
208+
// Determine if we're completing a partial match or continuing a path
209+
let newMentionText;
159210

160-
const newValue = `${beforeAt}@${newMentionText} ${afterAtAndMention}`;
211+
if (existingMentionText.length === 0) {
212+
// No existing mention, just use the entry name
213+
newMentionText = mentionText;
214+
} else {
215+
// Check if the existing text exactly matches the selected entry
216+
// This handles the case where user types "@.folder/" and presses Enter on ".folder"
217+
if (existingMentionText === mentionText) {
218+
// Exact match - user has already typed the complete name, don't duplicate
219+
newMentionText = mentionText;
220+
} else {
221+
// Check if we're completing a partial match (e.g., ".gi" -> ".git")
222+
// or continuing a path (e.g., ".git/" -> ".git/hooks")
223+
const lastSlashIndex = existingMentionText.lastIndexOf('/');
224+
225+
if (lastSlashIndex === existingMentionText.length - 1) {
226+
// Existing text ends with slash, we're continuing a path
227+
newMentionText = `${existingMentionText}${mentionText}`;
228+
} else {
229+
// No trailing slash, we're completing a partial match
230+
// Replace the partial match with the full entry name
231+
if (lastSlashIndex !== -1) {
232+
// There's a path before the partial match
233+
const pathPrefix = existingMentionText.substring(0, lastSlashIndex + 1);
234+
newMentionText = `${pathPrefix}${mentionText}`;
235+
} else {
236+
// No path, just replace the entire partial match
237+
newMentionText = mentionText;
238+
}
239+
}
240+
}
241+
}
242+
243+
// Only add space after mention if it's not a folder (folders end with / and shouldn't have space)
244+
const addSpace = !newMentionText.endsWith('/');
245+
const newValue = addSpace
246+
? `${beforeAt}@${newMentionText} ${afterAtAndMention}`
247+
: `${beforeAt}@${newMentionText}${afterAtAndMention}`;
161248

162249
console.log("🔥 [MentionInput] Building mention - existing:", existingMentionText, "entry:", mentionText, "final:", newMentionText);
163250
console.log("🔥 [MentionInput] New value:", newValue);
@@ -249,23 +336,38 @@ export function MentionInput({
249336
} else if (e.key === "Tab") {
250337
e.preventDefault();
251338
console.log("🔥 [MentionInput] Tab pressed");
252-
if (selectedEntry && selectedEntry.is_directory) {
253-
console.log("⌨️ [MentionInput] Tab: Adding folder to mentions AND navigating to directory:", selectedEntry.name);
339+
340+
if (selectedEntry) {
341+
const filteredEntries = getFilteredEntries();
342+
const hasDirectories = filteredEntries.some(entry => entry.is_directory);
254343

255-
// First, add the folder to mentions (but keep picker open)
256-
console.log("🔥 [MentionInput] About to call addMentionToInput with closePicker=false");
257-
addMentionToInput(selectedEntry, false);
344+
console.log("🎯 [MentionInput] Smart Tab logic - hasDirectories:", hasDirectories, "selectedEntry.is_directory:", selectedEntry.is_directory);
258345

259-
// Then navigate into the folder
260-
console.log("🔥 [MentionInput] About to call navigateToFolder to show contents");
261-
navActions.navigateToFolder(selectedEntry.name);
262-
console.log("🔥 [MentionInput] Clearing search filter");
263-
setSearchFilter(""); // Clear search when navigating
264-
console.log("🔥 [MentionInput] Resetting filteredSelectedIndex to 0");
265-
setFilteredSelectedIndex(0);
266-
console.log("🔥 [MentionInput] Tab completed - added mention and navigated");
346+
if (selectedEntry.is_directory && hasDirectories) {
347+
// There are directories to navigate into, so navigate
348+
console.log("⌨️ [MentionInput] Tab: Adding folder to mentions AND navigating to directory:", selectedEntry.name);
349+
350+
// First, add the folder to mentions (but keep picker open)
351+
console.log("🔥 [MentionInput] About to call addMentionToInput with closePicker=false");
352+
addMentionToInput(selectedEntry, false);
353+
354+
// Then navigate into the folder
355+
console.log("🔥 [MentionInput] About to call navigateToFolder to show contents");
356+
navActions.navigateToFolder(selectedEntry.name);
357+
console.log("🔥 [MentionInput] Clearing search filter");
358+
setSearchFilter(""); // Clear search when navigating
359+
console.log("🔥 [MentionInput] Resetting filteredSelectedIndex to 0");
360+
setFilteredSelectedIndex(0);
361+
console.log("🔥 [MentionInput] Tab completed - added mention and navigated");
362+
} else {
363+
// No directories to navigate into, or selected item is a file - behave like Enter
364+
console.log("⌨️ [MentionInput] Tab: No directories to navigate, selecting item like Enter:", selectedEntry.name);
365+
console.log("🔥 [MentionInput] About to call handleFileSelection");
366+
handleFileSelection(selectedEntry);
367+
console.log("🔥 [MentionInput] Tab selection completed");
368+
}
267369
} else {
268-
console.log("🔥 [MentionInput] Tab pressed but selectedEntry is not a directory or null");
370+
console.log("🔥 [MentionInput] Tab pressed but no selectedEntry");
269371
}
270372
return;
271373
} else if (e.key === "Escape") {

frontend/src/components/common/ToolCallDisplay.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -628,7 +628,7 @@ function ToolCallDisplayComponent({
628628
{enhancedToolCall.inputJsonRpc && (
629629
<div className="mt-4">
630630
<div className="text-xs font-semibold text-muted-foreground mb-2">
631-
Input:
631+
{t("toolCalls.input")}
632632
</div>
633633
<pre className="bg-muted p-3 rounded text-xs overflow-x-auto border">
634634
<code>{enhancedToolCall.inputJsonRpc}</code>
@@ -640,7 +640,7 @@ function ToolCallDisplayComponent({
640640
{enhancedToolCall.outputJsonRpc && (
641641
<div className="mt-4">
642642
<div className="text-xs font-semibold text-muted-foreground mb-2">
643-
Output:
643+
{t("toolCalls.output")}
644644
</div>
645645
<pre className="bg-muted p-3 rounded text-xs overflow-x-auto border">
646646
<code>{enhancedToolCall.outputJsonRpc}</code>

frontend/src/components/conversation/ThinkingBlock.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState } from "react";
22
import { ChevronDown, ChevronRight, Brain } from "lucide-react";
3+
import { useTranslation } from "react-i18next";
34
import ReactMarkdown from "react-markdown";
45
import remarkGfm from "remark-gfm";
56

@@ -8,6 +9,7 @@ interface ThinkingBlockProps {
89
}
910

1011
export function ThinkingBlock({ thinking }: ThinkingBlockProps) {
12+
const { t } = useTranslation();
1113
const [isExpanded, setIsExpanded] = useState(false);
1214

1315
if (!thinking || thinking.trim().length === 0) {
@@ -22,7 +24,7 @@ export function ThinkingBlock({ thinking }: ThinkingBlockProps) {
2224
>
2325
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
2426
<Brain className="w-4 h-4" />
25-
<span>Thinking</span>
27+
<span>{t("common.thinking")}</span>
2628
</div>
2729
{isExpanded ? (
2830
<ChevronDown className="w-4 h-4 text-muted-foreground" />

frontend/src/components/renderers/CommandRenderer.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Terminal, CheckCircle, XCircle, AlertTriangle } from "lucide-react";
2+
import { useTranslation } from "react-i18next";
23
import { type ToolCall } from "../../utils/toolCallParser";
34

45
interface CommandResult {
@@ -16,6 +17,7 @@ interface CommandRendererProps {
1617
}
1718

1819
export function CommandRenderer({ toolCall }: CommandRendererProps) {
20+
const { t } = useTranslation();
1921
const result = toolCall.result as CommandResult;
2022

2123
// Extract command from input
@@ -48,23 +50,23 @@ export function CommandRenderer({ toolCall }: CommandRendererProps) {
4850
color: "text-green-500",
4951
bgColor: "bg-green-50 dark:bg-green-950/20",
5052
borderColor: "border-green-200 dark:border-green-800",
51-
label: "Success",
53+
label: t("toolCalls.success"),
5254
};
5355
} else if (isSuccess && !hasOutput) {
5456
return {
5557
icon: CheckCircle,
5658
color: "text-green-500",
5759
bgColor: "bg-green-50 dark:bg-green-950/20",
5860
borderColor: "border-green-200 dark:border-green-800",
59-
label: "Completed",
61+
label: t("toolCalls.completed"),
6062
};
6163
} else {
6264
return {
6365
icon: XCircle,
6466
color: "text-red-500",
6567
bgColor: "bg-red-50 dark:bg-red-950/20",
6668
borderColor: "border-red-200 dark:border-red-800",
67-
label: "Failed",
69+
label: t("toolCalls.failed"),
6870
};
6971
}
7072
};

0 commit comments

Comments
 (0)