From 9801878ef37bf858e8e7e0b0c7c3407b7bd0c664 Mon Sep 17 00:00:00 2001 From: Type Int04 Date: Tue, 8 Sep 2026 22:08:13 +0700 Subject: [PATCH 1/6] feat: add ChatGPT projects link button --- web/src/tasks/TaskRail.tsx | 4 ++-- web/src/tasks/workspaceProjects.css | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/web/src/tasks/TaskRail.tsx b/web/src/tasks/TaskRail.tsx index e9adfd6d..6c15c623 100644 --- a/web/src/tasks/TaskRail.tsx +++ b/web/src/tasks/TaskRail.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, Bot, ChevronDown, ChevronUp, FolderOpen, LayoutDashboard, LoaderCircle, PanelLeftClose, PanelLeftOpen, Pencil, Plus, Power, Search, Settings, TerminalSquare, Trash2, Wrench } from 'lucide-react'; +import { AlertTriangle, Bot, ChevronDown, ChevronUp, ExternalLink, FolderOpen, LayoutDashboard, LoaderCircle, PanelLeftClose, PanelLeftOpen, Pencil, Plus, Power, Search, Settings, TerminalSquare, Trash2, Wrench } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type MouseEventHandler } from 'react'; import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom'; @@ -226,7 +226,7 @@ export function TaskRail({ open, onClose, onDesktopCollapse }: { open: boolean; {projectContextMenu &&
event.stopPropagation()}>
} {deleteTarget && !deleting && setDeleteTarget(undefined)} dangerous>
{tr('Warning')}

{tr('Deleting removes this conversation and its linked data from the list. This conversation may not work again in the future.')}

{deleteError &&

{deleteError}

}
} {deleteProjectTarget && !deletingProject && setDeleteProjectTarget(undefined)} dangerous>
Toàn bộ dự án sẽ bị xóa

Các cuộc trò chuyện đã hoàn tất trong dự án cũng sẽ bị xóa. Cuộc trò chuyện chưa hoàn tất sẽ được giữ lại và chuyển vào mục “Chưa phân loại”.

{deleteProjectError &&

{deleteProjectError}

}
} - {projectModalOpen && { if (!projectFolderPicking && !projectSaving) { setProjectModalOpen(false); setEditingProject(undefined); } }}>
{projectError &&

{projectError}

}
} + {projectModalOpen && { if (!projectFolderPicking && !projectSaving) { setProjectModalOpen(false); setEditingProject(undefined); } }}>
{projectError &&

{projectError}

}
} ; } diff --git a/web/src/tasks/workspaceProjects.css b/web/src/tasks/workspaceProjects.css index d127dc8b..5ca5ad52 100644 --- a/web/src/tasks/workspaceProjects.css +++ b/web/src/tasks/workspaceProjects.css @@ -35,6 +35,10 @@ .workspace-project-form label small{color:var(--subtle);font-size:9px;font-weight:500;line-height:1.5;overflow-wrap:anywhere} .workspace-project-form input{width:100%;min-height:45px;border:1px solid var(--border);border-radius:11px;background:var(--surface-2);padding:0 12px;color:var(--text);outline:0} .workspace-project-form input:focus{border-color:color-mix(in srgb,var(--accent) 58%,var(--border));box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 10%,transparent)} +.workspace-project-link-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;align-items:center} +.workspace-project-open-link{min-height:45px;display:inline-flex;align-items:center;justify-content:center;gap:7px;border:1px solid var(--border);border-radius:11px;background:var(--surface-2);padding:0 12px;color:var(--text);text-decoration:none;white-space:nowrap} +.workspace-project-open-link:hover{border-color:color-mix(in srgb,var(--accent) 40%,var(--border));background:var(--surface-3)} +.workspace-project-open-link svg{width:16px;height:16px;color:var(--accent)} .workspace-project-folder{width:100%;min-height:48px;display:flex;align-items:center;gap:10px;border:1px solid var(--border);border-radius:11px;background:var(--surface-2);padding:9px 12px;text-align:left;color:var(--text)} .workspace-project-folder:hover{border-color:color-mix(in srgb,var(--accent) 40%,var(--border));background:var(--surface-3)} .workspace-project-folder.empty{color:var(--muted)} From cc8e364b2149c3858efa5665f1168f02461bad04 Mon Sep 17 00:00:00 2001 From: Type Int04 Date: Tue, 8 Sep 2026 22:15:20 +0700 Subject: [PATCH 2/6] feat: prioritize recently used MCP agents --- web/src/chatgpt/ChatGptConversation.tsx | 23 +++++++++++++---- web/src/chatgpt/agentRecency.ts | 30 ++++++++++++++++++++++ web/src/tasks/TaskRail.tsx | 2 +- web/src/test/agentRecency.test.ts | 33 +++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 web/src/chatgpt/agentRecency.ts create mode 100644 web/src/test/agentRecency.test.ts diff --git a/web/src/chatgpt/ChatGptConversation.tsx b/web/src/chatgpt/ChatGptConversation.tsx index 0bcac141..e049ef99 100644 --- a/web/src/chatgpt/ChatGptConversation.tsx +++ b/web/src/chatgpt/ChatGptConversation.tsx @@ -9,6 +9,7 @@ import { Modal } from '../components'; import { tr } from '../i18n'; import { canonicalProjectPath } from '../tasks/workspaceProjects'; import type { Agent } from '../types'; +import { orderAgentsByRecentUse, rememberAgentUse } from './agentRecency'; import { useLoad } from '../useLoad'; export { ChatGptTaskComposer } from './ChatGptTaskComposer'; import { useCompactBridgeSync } from './compact/useCompactBridgeSync'; @@ -23,8 +24,13 @@ export function NewChatGptConversation() { const navigate = useNavigate(); const launchProjectFolder = routeProjectFolder(location.state); const launchProjectChatGptUrl = routeProjectChatGptUrl(location.state); - const enabledAgents = useMemo(() => (agents.data ?? []).filter((agent) => agent.enabled), [agents.data]); + const preferRecentAgents = routeAgentOrder(location.state) === 'recent'; + const enabledAgents = useMemo(() => { + const enabled = (agents.data ?? []).filter((agent) => agent.enabled); + return preferRecentAgents ? orderAgentsByRecentUse(enabled) : enabled; + }, [agents.data, preferRecentAgents]); const [agentId, setAgentId] = useState(''); + const agentLaunchKey = useRef(location.key); const [projectFolder, setProjectFolder] = useState(launchProjectFolder); const [folderMenuOpen, setFolderMenuOpen] = useState(false); const [content, setContent] = useState(''); @@ -40,10 +46,12 @@ export function NewChatGptConversation() { const [busy, setBusy] = useState(false); const [error, setError] = useState(''); useEffect(() => { - if (!agentId && enabledAgents[0]) { - setAgentId(enabledAgents[0].id); - } - }, [agentId, enabledAgents]); + const routeChanged = agentLaunchKey.current !== location.key; + if (routeChanged) agentLaunchKey.current = location.key; + const currentAvailable = enabledAgents.some((agent) => agent.id === agentId); + if ((routeChanged || !currentAvailable) && enabledAgents[0]) setAgentId(enabledAgents[0].id); + if (!enabledAgents.length && agentId) setAgentId(''); + }, [agentId, enabledAgents, location.key]); useEffect(() => { if (!launchProjectFolder) return; setProjectFolder(launchProjectFolder); @@ -117,6 +125,7 @@ export function NewChatGptConversation() { setExtensionReady(status.ready); setChatGptTabOpen(status.chatGptTabOpen); if (!status.ready) throw new Error(tr('ChatCMD ChatGPT Bridge extension is not ready. Enable or reload it, then try again.')); const request = await api.createChatGptRequest({ agentId, model: DEFAULT_MODEL, projectFolder: projectFolder.trim(), content: effectiveContent }); + rememberAgentUse(agentId); await dispatchChatGptRequest({ requestId: request.id, submittedContent: request.submittedContent, model: request.model, newConversationUrl, attachments: fileAttachmentPayloads(textAttachments) }); const taskId = await waitForTaskBinding(request.id); navigate(`/tasks/${encodeURIComponent(taskId)}`, { replace: true }); @@ -248,5 +257,9 @@ function routeProjectChatGptUrl(state: unknown) { const value = (state as Record).chatGptProjectUrl; return typeof value === 'string' ? value.trim() : ''; } +function routeAgentOrder(state: unknown) { + if (!state || typeof state !== 'object' || Array.isArray(state)) return ''; + return (state as Record).agentOrder === 'recent' ? 'recent' : ''; +} function errorText(reason: unknown) { return reason instanceof Error ? reason.message : tr('Could not complete the ChatGPT request.'); } diff --git a/web/src/chatgpt/agentRecency.ts b/web/src/chatgpt/agentRecency.ts new file mode 100644 index 00000000..65e610e5 --- /dev/null +++ b/web/src/chatgpt/agentRecency.ts @@ -0,0 +1,30 @@ +import type { Agent } from '../types'; + +const RECENT_AGENT_IDS_KEY = 'chatcmd.chatgpt.recentAgentIds.v1'; +const MAX_RECENT_AGENTS = 50; + +export function orderAgentsByRecentUse(agents: Agent[]): Agent[] { + const positions = new Map(readRecentAgentIds().map((id, index) => [id, index])); + return [...agents].sort((left, right) => { + const leftIndex = positions.get(left.id) ?? Number.MAX_SAFE_INTEGER; + const rightIndex = positions.get(right.id) ?? Number.MAX_SAFE_INTEGER; + return leftIndex - rightIndex; + }); +} + +export function rememberAgentUse(agentId: string) { + const trimmed = agentId.trim(); + if (!trimmed || typeof localStorage === 'undefined') return; + try { + const next = [trimmed, ...readRecentAgentIds().filter((id) => id !== trimmed)].slice(0, MAX_RECENT_AGENTS); + localStorage.setItem(RECENT_AGENT_IDS_KEY, JSON.stringify(next)); + } catch { /* storage can be unavailable */ } +} + +function readRecentAgentIds(): string[] { + if (typeof localStorage === 'undefined') return []; + try { + const value = JSON.parse(localStorage.getItem(RECENT_AGENT_IDS_KEY) ?? '[]'); + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string' && item.length > 0) : []; + } catch { return []; } +} diff --git a/web/src/tasks/TaskRail.tsx b/web/src/tasks/TaskRail.tsx index 6c15c623..d4c7f3e3 100644 --- a/web/src/tasks/TaskRail.tsx +++ b/web/src/tasks/TaskRail.tsx @@ -208,7 +208,7 @@ export function TaskRail({ open, onClose, onDesktopCollapse }: { open: boolean;
- +
Dự án
diff --git a/web/src/test/agentRecency.test.ts b/web/src/test/agentRecency.test.ts new file mode 100644 index 00000000..dbc94873 --- /dev/null +++ b/web/src/test/agentRecency.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { orderAgentsByRecentUse, rememberAgentUse } from '../chatgpt/agentRecency'; +import type { Agent } from '../types'; + +const agents: Agent[] = [ + { id: 'configured-first', name: 'Configured First', enabled: true, toolIds: [] }, + { id: 'recent', name: 'Recent', enabled: true, toolIds: [] }, + { id: 'older', name: 'Older', enabled: true, toolIds: [] }, +]; + +describe('ChatGPT MCP agent recency', () => { + beforeEach(() => localStorage.clear()); + + it('keeps configured order when there is no usage history', () => { + expect(orderAgentsByRecentUse(agents).map((agent) => agent.id)).toEqual(['configured-first', 'recent', 'older']); + }); + + it('orders agents by most recent successful use and preserves configured order for unseen agents', () => { + rememberAgentUse('older'); + rememberAgentUse('recent'); + + expect(orderAgentsByRecentUse(agents).map((agent) => agent.id)).toEqual(['recent', 'older', 'configured-first']); + }); + + it('moves a reused agent back to the front without duplicating it', () => { + rememberAgentUse('recent'); + rememberAgentUse('older'); + rememberAgentUse('recent'); + + expect(orderAgentsByRecentUse(agents).map((agent) => agent.id)).toEqual(['recent', 'older', 'configured-first']); + }); +}); From a439dfd42a0000395bf8437a7324b798d0a9ce08 Mon Sep 17 00:00:00 2001 From: DucNghia Date: Wed, 9 Sep 2026 08:02:46 +0700 Subject: [PATCH 3/6] Remove extension setup quick action buttons --- web/src/extensions/ExtensionSetupPage.tsx | 24 ++--------------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/web/src/extensions/ExtensionSetupPage.tsx b/web/src/extensions/ExtensionSetupPage.tsx index f22313b7..1fa3e454 100644 --- a/web/src/extensions/ExtensionSetupPage.tsx +++ b/web/src/extensions/ExtensionSetupPage.tsx @@ -1,12 +1,11 @@ -import { CheckCircle2, ExternalLink, FolderOpen, Puzzle, RefreshCw, Settings2 } from 'lucide-react'; +import { CheckCircle2, Puzzle, RefreshCw, Settings2 } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; -import { api } from '../api'; import { chatGptExtensionStatus, REQUIRED_CHATGPT_EXTENSION_VERSION, type ChatGptExtensionStatus, } from '../chatgptBridge'; -import { PageHeading, ProblemBanner } from '../components'; +import { PageHeading } from '../components'; import { useAppLanguage } from '../i18n'; import { extensionCopy } from './copy'; @@ -30,8 +29,6 @@ export function ExtensionSetupPage() { const { name: browserName, target: browserTarget } = browserDetails[browser]; const [status, setStatus] = useState(); const [checking, setChecking] = useState(false); - const [busyAction, setBusyAction] = useState<'browser' | 'folder'>(); - const [error, setError] = useState(''); useEffect(() => { let active = true; @@ -52,19 +49,6 @@ export function ExtensionSetupPage() { const statusLabel = compatible ? copy.connected : status?.ready ? copy.outdated : copy.missing; const statusClass = compatible ? 'good' : 'warn'; - const runAction = async (action: 'browser' | 'folder') => { - setBusyAction(action); - setError(''); - try { - if (action === 'browser') await api.openBrowserExtensions(browser); - else await api.openChatGptExtensionFolder(); - } catch (reason) { - setError(reason instanceof Error ? reason.message : String(reason)); - } finally { - setBusyAction(undefined); - } - }; - return
{statusLabel}} /> - setError('')} /> -
{copy.currentVersion}{status?.extensionVersion || copy.notDetected}
@@ -85,11 +67,9 @@ export function ExtensionSetupPage() {
{copy.quickActions}{browserName}
{browserTarget}{copy.openBrowserHint}
-
chatgpt-extension{copy.openFolderHint}
-
From 033789b9b54e246324ecddb6358ef86c31031233 Mon Sep 17 00:00:00 2001 From: DucNghia Date: Wed, 9 Sep 2026 08:09:57 +0700 Subject: [PATCH 4/6] Fix git commit stack overflow and auto-stage all scope --- crates/chatcmd-mcp/src/tool_methods.rs | 2 +- crates/chatcmd-runtime/src/git_service.rs | 44 +++++-- .../chatcmd-runtime/src/git_service/commit.rs | 119 ++++++++++++++++-- .../tests/git_commit_index_safety.rs | 73 +++++++++++ 4 files changed, 218 insertions(+), 20 deletions(-) diff --git a/crates/chatcmd-mcp/src/tool_methods.rs b/crates/chatcmd-mcp/src/tool_methods.rs index 48921396..d4d5e7c8 100644 --- a/crates/chatcmd-mcp/src/tool_methods.rs +++ b/crates/chatcmd-mcp/src/tool_methods.rs @@ -263,7 +263,7 @@ tool_methods!( ( git_commit, GitCommitArgs, - "Create or preview a Git commit without shell interpolation. Required field: message and exactly one explicit scope: non-empty normalized paths or all=true. all defaults to false, is mutually exclusive with paths, commits only already-staged changes, and fails closed while unstaged or untracked changes exist. Set previewOnly=true for a side-effect-free GitCommitPreview; pass it back as expectedPreview to bind execution to the previewed HEAD/index/worktree bytes. The runtime refuses stale previews, staged paths outside scope, ambiguous path spellings, and selected paths with mixed staged/unstaged changes." + "Create or preview a Git commit without shell interpolation. Required field: message and exactly one explicit scope: non-empty normalized paths or all=true. all defaults to false and is mutually exclusive with paths. With all=true, the runtime previews the full worktree, snapshots the existing Git index, stages all tracked/untracked changes with git add --all, and restores the previous index if staging or commit fails before HEAD changes. Set previewOnly=true for a side-effect-free GitCommitPreview; pass it back as expectedPreview to bind execution to the previewed HEAD/index/worktree bytes. For path-scoped commits, the runtime refuses stale previews, staged paths outside scope, ambiguous path spellings, and selected paths with mixed staged/unstaged changes." ), ( process_list, diff --git a/crates/chatcmd-runtime/src/git_service.rs b/crates/chatcmd-runtime/src/git_service.rs index 5795e68b..b24df0ab 100644 --- a/crates/chatcmd-runtime/src/git_service.rs +++ b/crates/chatcmd-runtime/src/git_service.rs @@ -291,7 +291,15 @@ impl GitService { options: &GitRunOptions, cancellation: CancellationToken, ) -> RuntimeResult { - commit::preview(self, cwd, all, paths, options, cancellation).await + let service = self.clone(); + let cwd = cwd.to_path_buf(); + let paths = paths.to_vec(); + let options = options.clone(); + tokio::spawn(async move { + commit::preview(&service, &cwd, all, &paths, &options, cancellation).await + }) + .await + .map_err(commit_worker_error)? } #[allow(clippy::too_many_arguments)] @@ -305,17 +313,27 @@ impl GitService { options: &GitRunOptions, cancellation: CancellationToken, ) -> RuntimeResult { - commit::execute( - self, - cwd, - message, - all, - paths, - preview, - options, - cancellation, - ) + let service = self.clone(); + let cwd = cwd.to_path_buf(); + let message = message.to_owned(); + let paths = paths.to_vec(); + let preview = preview.clone(); + let options = options.clone(); + tokio::spawn(async move { + commit::execute( + &service, + &cwd, + &message, + all, + &paths, + &preview, + &options, + cancellation, + ) + .await + }) .await + .map_err(commit_worker_error)? } async fn run( @@ -404,6 +422,10 @@ fn join_error(error: tokio::task::JoinError) -> RuntimeError { RuntimeError::new("git_parse_failed", error.to_string()) } +fn commit_worker_error(error: tokio::task::JoinError) -> RuntimeError { + RuntimeError::new("git_commit_worker_failed", error.to_string()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/chatcmd-runtime/src/git_service/commit.rs b/crates/chatcmd-runtime/src/git_service/commit.rs index 95d63145..60b4aa74 100644 --- a/crates/chatcmd-runtime/src/git_service/commit.rs +++ b/crates/chatcmd-runtime/src/git_service/commit.rs @@ -5,7 +5,11 @@ use crate::{ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; -use std::{collections::BTreeSet, path::Path}; +use std::{ + collections::BTreeSet, + io::ErrorKind, + path::{Path, PathBuf}, +}; use tokio_util::sync::CancellationToken; mod inspection; @@ -29,6 +33,71 @@ pub struct GitCommitPreview { pub all: bool, } +struct IndexSnapshot { + path: PathBuf, + bytes: Option>, +} + +impl IndexSnapshot { + async fn capture( + service: &GitService, + cwd: &Path, + options: &GitRunOptions, + cancellation: CancellationToken, + ) -> RuntimeResult { + let output = inspect_output( + service, + cwd, + &["rev-parse", "--git-path", "index"], + options, + cancellation, + ) + .await?; + let raw_path = output.stdout.trim(); + if raw_path.is_empty() { + return Err(RuntimeError::new( + "git_index_snapshot_failed", + "git returned an empty index path", + )); + } + let path = { + let candidate = PathBuf::from(raw_path); + if candidate.is_absolute() { + candidate + } else { + cwd.join(candidate) + } + }; + let bytes = match tokio::fs::read(&path).await { + Ok(bytes) => Some(bytes), + Err(error) if error.kind() == ErrorKind::NotFound => None, + Err(error) => { + return Err(RuntimeError::new( + "git_index_snapshot_failed", + error.to_string(), + )); + } + }; + Ok(Self { path, bytes }) + } + + async fn restore(&self) -> RuntimeResult<()> { + match self.bytes.as_ref() { + Some(bytes) => tokio::fs::write(&self.path, bytes) + .await + .map_err(|error| RuntimeError::new("git_index_restore_failed", error.to_string())), + None => match tokio::fs::remove_file(&self.path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), + Err(error) => Err(RuntimeError::new( + "git_index_restore_failed", + error.to_string(), + )), + }, + } + } +} + pub(super) async fn preview( service: &GitService, cwd: &Path, @@ -97,6 +166,29 @@ pub(super) async fn execute( } validate_preview(¤t)?; + let index_snapshot = if all { + let snapshot = IndexSnapshot::capture(service, cwd, options, cancellation.clone()).await?; + let stage_args = vec!["add".to_owned(), "--all".to_owned(), "--".to_owned()]; + let mut staged = match service + .run_owned(cwd, &stage_args, options, cancellation.clone()) + .await + { + Ok(output) => output, + Err(error) => { + snapshot.restore().await?; + return Err(error); + } + }; + if !succeeded(&staged) { + snapshot.restore().await?; + set_commit_phase(&mut staged, "staging", false, None); + return Ok(staged); + } + Some(snapshot) + } else { + None + }; + let mut args = vec![ "commit".to_owned(), "--message".to_owned(), @@ -107,12 +199,28 @@ pub(super) async fn execute( args.push("--".to_owned()); args.extend(current.scope_paths.iter().cloned()); } - let mut committed = service + let mut committed = match service .run_owned(cwd, &args, options, cancellation.clone()) - .await?; + .await + { + Ok(output) => output, + Err(error) => { + if let Some(snapshot) = index_snapshot.as_ref() { + let observed_head = + commit_hash(service, cwd, options, CancellationToken::new()).await?; + if observed_head == current.head { + snapshot.restore().await?; + } + } + return Err(error); + } + }; if !succeeded(&committed) { let observed_head = commit_hash(service, cwd, options, CancellationToken::new()).await?; let changed = observed_head != current.head; + if !changed && let Some(snapshot) = index_snapshot.as_ref() { + snapshot.restore().await?; + } set_commit_phase( &mut committed, if changed { @@ -158,11 +266,6 @@ fn validate_preview(preview: &GitCommitPreview) -> RuntimeResult<()> { ))); } if preview.all { - if !preview.unstaged_paths.is_empty() || !preview.untracked_paths.is_empty() { - return Err(scope_conflict( - "all=true requires every intended change to be staged before preview; automatic staging is disabled to preserve the index on commit failure", - )); - } return Ok(()); } let selected = |path: &str| { diff --git a/crates/chatcmd-runtime/tests/git_commit_index_safety.rs b/crates/chatcmd-runtime/tests/git_commit_index_safety.rs index 304b2d1d..ab41d4b0 100644 --- a/crates/chatcmd-runtime/tests/git_commit_index_safety.rs +++ b/crates/chatcmd-runtime/tests/git_commit_index_safety.rs @@ -133,6 +133,79 @@ async fn failing_commit_hook_preserves_the_preexisting_index() { assert_failure_preserves_index(&directory, "hook failure").await; } +#[tokio::test] +async fn all_scope_auto_stages_tracked_and_untracked_changes() { + let directory = staged_repository(); + git( + directory.path(), + &["commit", "--quiet", "--message", "staged base"], + ); + std::fs::write(directory.path().join("tracked.txt"), "updated\n").expect("update tracked"); + std::fs::write(directory.path().join("new.txt"), "new\n").expect("write untracked"); + + let output = service(directory.path()) + .commit_with_options( + directory.path(), + "auto stage all", + true, + &[], + &GitRunOptions::default(), + CancellationToken::new(), + ) + .await + .expect("commit all"); + + assert_eq!(output.exit_code, Some(0), "output={output:?}"); + assert_eq!( + git(directory.path(), &["show", "HEAD:tracked.txt"]), + "updated\n" + ); + assert_eq!(git(directory.path(), &["show", "HEAD:new.txt"]), "new\n"); + assert!(git(directory.path(), &["status", "--porcelain=v1"]).is_empty()); +} + +#[cfg(unix)] +#[tokio::test] +async fn all_scope_failed_hook_restores_index_after_auto_staging() { + let directory = staged_repository(); + let index_before = git(directory.path(), &["diff", "--cached", "--binary"]); + std::fs::write( + directory.path().join("tracked.txt"), + "staged plus unstaged\n", + ) + .expect("write unstaged tracked change"); + std::fs::write(directory.path().join("new.txt"), "untracked\n").expect("write untracked"); + let hook = directory.path().join(".git/hooks/pre-commit"); + std::fs::write(&hook, "#!/bin/sh\nexit 1\n").expect("write hook"); + use std::os::unix::fs::PermissionsExt as _; + let mut permissions = std::fs::metadata(&hook) + .expect("hook metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&hook, permissions).expect("make hook executable"); + + let output = service(directory.path()) + .commit_with_options( + directory.path(), + "auto stage hook failure", + true, + &[], + &GitRunOptions::default(), + CancellationToken::new(), + ) + .await + .expect("structured git failure"); + + assert_ne!(output.exit_code, Some(0)); + assert_eq!( + git(directory.path(), &["diff", "--cached", "--binary"]), + index_before + ); + let status = git(directory.path(), &["status", "--porcelain=v1"]); + assert!(status.contains("MM tracked.txt")); + assert!(status.contains("?? new.txt")); +} + #[cfg(any(unix, windows))] #[tokio::test] async fn symlinked_parent_is_rejected_without_reading_or_committing_outside_content() { From 52b64dc04f8c9eeb2e08f2c4571c617f18750394 Mon Sep 17 00:00:00 2001 From: DucNghia Date: Wed, 9 Sep 2026 08:41:33 +0700 Subject: [PATCH 5/6] Fix mixed Vietnamese and English UI localization --- web/src/chatgpt/ChatGptConversation.tsx | 26 +- web/src/chatgpt/ChatGptTaskComposer.tsx | 16 +- web/src/chatgpt/compact/CompactControls.tsx | 21 +- .../chatgpt/compact/CompactHistoryCard.tsx | 4 +- web/src/chatgpt/compact/types.ts | 2 +- web/src/i18n.ts | 224 ++++++++++++++++++ web/src/pages/TasksPage.tsx | 2 +- web/src/settings/DataSettings.tsx | 16 +- .../tasks/GlobalConversationApprovalQueue.tsx | 4 +- web/src/tasks/TaskRail.tsx | 54 ++--- 10 files changed, 297 insertions(+), 72 deletions(-) diff --git a/web/src/chatgpt/ChatGptConversation.tsx b/web/src/chatgpt/ChatGptConversation.tsx index e049ef99..e3be9e4d 100644 --- a/web/src/chatgpt/ChatGptConversation.tsx +++ b/web/src/chatgpt/ChatGptConversation.tsx @@ -86,7 +86,7 @@ export function NewChatGptConversation() { setFolderMenuOpen(false); } } catch (reason) { - setError(reason instanceof Error ? reason.message : 'Không thể mở trình chọn thư mục.'); + setError(reason instanceof Error ? reason.message : tr('Could not open the folder picker.')); } finally { setFolderPicking(false); } }; @@ -160,7 +160,7 @@ export function NewChatGptConversation() {
-
ChatGPT

{selectedAgent ? `Bạn muốn mình giao công việc gì cho @${selectedAgent.name}?` : 'Chọn một MCP agent để bắt đầu cuộc trò chuyện.'}

Yêu cầu của bạn sẽ được gửi qua ChatGPT và agent sẽ thực hiện công việc trong ChatCMD.
+
ChatGPT

{selectedAgent ? tr('What would you like me to assign to @{name}?', { name: selectedAgent.name }) : tr('Choose an MCP agent to start the conversation.')}

{tr('Your request will be sent through ChatGPT and the agent will perform the work in ChatCMD.')}
{(content.trim() || textAttachments.length > 0) &&
{content.trim() ? content : effectiveContent}
}
@@ -173,12 +173,12 @@ export function NewChatGptConversation() { {enabledAgents.map((agent) => )}
- Thư mục dự án + {tr('Project folder')}
- - {projectFolder && } + {projectFolder && }
@@ -191,22 +191,22 @@ export function NewChatGptConversation() {
- {textAttachments.length > 0 &&
- {textAttachments.map((attachment) => {attachment.name})} + {textAttachments.length > 0 &&
+ {textAttachments.map((attachment) => {attachment.name})}
}