diff --git a/README.md b/README.md index a99d5aa5..49fc67b5 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,8 @@ This makes Sub-Agents useful for parallel code inspection, splitting research ac While ChatGPT is still working, ChatCMD lets you prepare the next instruction without waiting for the current response to finish. The task composer exposes two different delivery modes: -- **Queue another message (`Gửi thêm tin nhắn`):** adds the message to the task's persistent ChatGPT queue. ChatCMD keeps it waiting until the current conversation is idle, the browser bridge is connected, the exact ChatGPT tab is open, and the UI is ready for another prompt; it then sends the first queued message automatically. -- **Send immediate message (`Gửi ngay tin nhắn`):** marks the message as `immediate`, allowing the AI to receive it on its next MCP call in the same conversation instead of waiting for the normal browser-send window. If the active turn ends before that happens, the message remains available as a normal queued follow-up rather than being lost. +- **Queue another message:** adds the message to the task's persistent ChatGPT queue. ChatCMD keeps it waiting until the current conversation is idle, the browser bridge is connected, the exact ChatGPT tab is open, and the UI is ready for another prompt; it then sends the first queued message automatically. +- **Send immediate message:** marks the message as `immediate`, allowing the AI to receive it on its next MCP call in the same conversation instead of waiting for the normal browser-send window. If the active turn ends before that happens, the message remains available as a normal queued follow-up rather than being lost. Queued follow-ups are manageable directly from the task UI: messages can be reordered, edited, deleted, promoted from queued to immediate, or demoted back to normal queue mode. Realtime queue events keep the panel synchronized when messages are consumed, and automatic sending is paused while compact/resume, bridge synchronization, another send, or an edit is in progress. @@ -110,7 +110,7 @@ Long ChatGPT conversations eventually become harder to continue reliably as thei How it works: -1. **Confirm before anything is sent.** Selecting **Compact & resume now** opens a confirmation dialog. The optional **Tiếp tục công việc sau khi compact xong** checkbox starts unchecked on every opening; leave it unchecked to transfer context only, or opt in to automatically continue the working request after the replacement chat has been attached. +1. **Confirm before anything is sent.** Selecting **Compact & resume now** opens a confirmation dialog. The optional **Continue work after compaction** checkbox starts unchecked on every opening; leave it unchecked to transfer context only, or opt in to automatically continue the working request after the replacement chat has been attached. 2. **Freeze the task at a safe boundary.** ChatCMD fences new local MCP operations for the compacting task, waits for already-admitted operations to finish, and stops the current ChatGPT generation before asking the source conversation to produce its handoff. Existing drafts and queued follow-up messages are preserved rather than overwritten. 3. **Write and persist the handoff first.** The source ChatGPT conversation receives a structured handoff request covering requirements, corrections, completed versus planned work, bug/fix/evidence chains, delegated work, environment details, blockers, and remaining tasks. The resulting public answer is saved durably in SQLite before ChatCMD is allowed to open or commit a replacement conversation. 4. **Bootstrap a fresh ChatGPT conversation.** ChatCMD opens a new conversation and sends a no-tools resume/bootstrap message containing the saved handoff. It waits until the destination's real canonical ChatGPT conversation identity and resume marker are observed before changing the task's active conversation binding. @@ -118,7 +118,7 @@ How it works: 6. **Optionally continue the work.** Only when the saved opt-in is enabled does ChatCMD enqueue the deterministic post-handoff continuation request. Repeated resume calls are idempotent, so recovery or retries do not create duplicate working messages. 7. **Retire the source tab conservatively.** After the new conversation is safely attached, ChatCMD attempts to close only the exact recorded source tab. If tab identity, draft state, generation state, or dispatch ownership is ambiguous, it leaves the tab open rather than risking closure of the wrong conversation. -The UI shows live phases for preparing, writing the handoff, saving it, and opening the new chat. Compact state is persisted independently of the browser worker, so extension reloads, closed/reopened tabs, delayed Send availability, or lost responses can be reconciled from SQLite plus browser dispatch metadata instead of blindly repeating prompts. Completed compactions remain available in **Lịch sử thu gọn ngữ cảnh**, including references to the archived source and replacement conversation. +The UI shows live phases for preparing, writing the handoff, saving it, and opening the new chat. Compact state is persisted independently of the browser worker, so extension reloads, closed/reopened tabs, delayed Send availability, or lost responses can be reconciled from SQLite plus browser dispatch metadata instead of blindly repeating prompts. Completed compactions remain available in **Context compaction history**, including references to the archived source and replacement conversation. Compact & resume is intentionally fail-closed: if ChatCMD cannot prove which prompt was sent, which conversation produced the handoff, or which destination owns the resume marker, it pauses with a recoverable state instead of silently dropping context or binding the task to the wrong chat. See [docs/COMPACT_RESUME.md](docs/COMPACT_RESUME.md) for the full persistence, identity, recovery, and dispatch model. diff --git a/README_vi.md b/README_vi.md index dd160d2c..0ab8363f 100644 --- a/README_vi.md +++ b/README_vi.md @@ -30,7 +30,7 @@ ChatCMD là một cầu nối tự host giữa các AI client tương thích MCP Tải ChatCMD cho macOS Intel

-[Xem release notes và mã SHA-256](https://github.com/int04/ChatCmd/releases/latest). +[Xem ghi chú phát hành và mã SHA-256](https://github.com/int04/ChatCmd/releases/latest). Các gói macOS được build tự động hiện dùng ad-hoc signing và chưa được Apple notarize. @@ -91,20 +91,20 @@ Sub-Agent đặc biệt hữu ích khi cần chia code inspection chạy song so Trong khi ChatGPT vẫn đang xử lý, ChatCMD cho phép bạn chuẩn bị instruction tiếp theo mà không cần đợi response hiện tại hoàn tất. Task composer có hai chế độ gửi khác nhau: -- **Gửi thêm tin nhắn (`Queue another message`):** đưa message vào hàng đợi ChatGPT bền vững của task. ChatCMD giữ message ở trạng thái chờ cho đến khi conversation hiện tại idle, browser bridge đã kết nối, đúng tab ChatGPT đang mở và UI sẵn sàng nhận prompt mới; sau đó message đầu tiên trong queue sẽ được gửi tự động. -- **Gửi ngay tin nhắn (`Send immediate message`):** đánh dấu message là `immediate`, cho phép AI nhận nó ở MCP call kế tiếp trong cùng conversation thay vì phải đợi cửa sổ gửi qua browser bình thường. Nếu active turn kết thúc trước thời điểm đó, message vẫn được giữ lại như một queued follow-up thông thường thay vì bị mất. +- **Gửi thêm tin nhắn:** đưa message vào hàng đợi ChatGPT bền vững của task. ChatCMD giữ message ở trạng thái chờ cho đến khi conversation hiện tại idle, browser bridge đã kết nối, đúng tab ChatGPT đang mở và UI sẵn sàng nhận prompt mới; sau đó message đầu tiên trong queue sẽ được gửi tự động. +- **Gửi ngay tin nhắn:** đánh dấu message là `immediate`, cho phép AI nhận nó ở MCP call kế tiếp trong cùng conversation thay vì phải đợi cửa sổ gửi qua browser bình thường. Nếu active turn kết thúc trước thời điểm đó, message vẫn được giữ lại như một queued follow-up thông thường thay vì bị mất. Các follow-up đang xếp hàng có thể quản lý trực tiếp trên task UI: đổi thứ tự, sửa, xóa, nâng từ queued lên immediate hoặc hạ từ immediate về queue bình thường. Realtime queue event giữ panel luôn đồng bộ khi message được consume, và auto-send sẽ tạm dừng trong lúc compact/resume, bridge synchronization, một send khác hoặc một edit đang diễn ra. Tính năng này hữu ích khi bạn đã biết bước tiếp theo cần làm: có thể xếp sẵn nhiều instruction để thực hiện tuần tự hoặc chèn một instruction ưu tiên cao vào MCP workflow hiện tại mà không cần ngồi chờ từng ChatGPT turn sẵn sàng. -### Compact & resume now +### Thu gọn và tiếp tục ngay -Các hội thoại ChatGPT dài dần sẽ khó tiếp tục ổn định khi usable context gần đầy. **Compact & resume now** tạo một handoff bền vững từ conversation hiện tại sang một conversation mới, đồng thời vẫn giữ nguyên ChatCMD task, project, permission, timeline, queued message và local task identity. +Các hội thoại ChatGPT dài dần sẽ khó tiếp tục ổn định khi usable context gần đầy. **Thu gọn và tiếp tục ngay** tạo một handoff bền vững từ conversation hiện tại sang một conversation mới, đồng thời vẫn giữ nguyên ChatCMD task, project, permission, timeline, queued message và local task identity. Cơ chế hoạt động: -1. **Luôn xác nhận trước khi gửi bất cứ thứ gì.** Chọn **Compact & resume now** sẽ mở confirmation dialog. Checkbox tùy chọn **Tiếp tục công việc sau khi compact xong** luôn mặc định chưa chọn mỗi lần mở; để trống nếu chỉ muốn chuyển context, hoặc bật lên nếu muốn tự động tiếp tục công việc sau khi replacement chat được attach. +1. **Luôn xác nhận trước khi gửi bất cứ thứ gì.** Chọn **Thu gọn và tiếp tục ngay** sẽ mở confirmation dialog. Checkbox tùy chọn **Tiếp tục công việc sau khi compact xong** luôn mặc định chưa chọn mỗi lần mở; để trống nếu chỉ muốn chuyển context, hoặc bật lên nếu muốn tự động tiếp tục công việc sau khi replacement chat được attach. 2. **Đóng băng task tại một ranh giới an toàn.** ChatCMD chặn local MCP operation mới trên task đang compact, đợi các operation đã được admit chạy xong và dừng generation ChatGPT hiện tại trước khi yêu cầu source conversation tạo handoff. Draft hiện có và queued follow-up message vẫn được giữ nguyên, không bị ghi đè. 3. **Viết và persist handoff trước.** Source ChatGPT conversation nhận một structured handoff request bao gồm requirement, correction, trạng thái completed so với planned, chuỗi bug/fix/evidence, delegated work, environment detail, blocker và task còn lại. Public answer tạo ra được lưu bền vững trong SQLite trước khi ChatCMD được phép mở hoặc commit replacement conversation. 4. **Bootstrap một ChatGPT conversation mới.** ChatCMD mở conversation mới và gửi no-tools resume/bootstrap message chứa handoff đã lưu. Hệ thống đợi đến khi quan sát được canonical ChatGPT conversation identity thực tế cùng resume marker của destination trước khi thay đổi active conversation binding của task. @@ -114,11 +114,11 @@ Cơ chế hoạt động: UI hiển thị trực tiếp các phase: chuẩn bị, viết handoff, lưu handoff và mở chat mới. Compact state được persist độc lập với browser worker, vì vậy extension reload, tab đóng/mở lại, Send enable chậm hoặc response thất lạc đều có thể reconcile từ SQLite kết hợp browser dispatch metadata thay vì gửi lại prompt một cách mù quáng. Các lần compact hoàn tất vẫn xuất hiện trong **Lịch sử thu gọn ngữ cảnh**, kèm reference tới source conversation đã archive và replacement conversation. -Compact & resume được thiết kế theo nguyên tắc fail-closed: nếu ChatCMD không thể chứng minh prompt nào đã được gửi, conversation nào đã tạo handoff hoặc destination nào đang sở hữu resume marker, hệ thống sẽ dừng ở trạng thái có thể recovery thay vì âm thầm làm mất context hoặc bind task vào sai chat. Xem [docs/COMPACT_RESUME.md](docs/COMPACT_RESUME.md) để biết đầy đủ persistence, identity, recovery và dispatch model. +Tính năng thu gọn và tiếp tục được thiết kế theo nguyên tắc fail-closed: nếu ChatCMD không thể chứng minh prompt nào đã được gửi, conversation nào đã tạo handoff hoặc destination nào đang sở hữu resume marker, hệ thống sẽ dừng ở trạng thái có thể recovery thay vì âm thầm làm mất context hoặc bind task vào sai chat. Xem [docs/COMPACT_RESUME.md](docs/COMPACT_RESUME.md) để biết đầy đủ persistence, identity, recovery và dispatch model. ### Giao diện quản trị -- Runtime dashboard cho app, database, MCP listener, task, terminal, approval và client health. +- Bảng điều khiển hệ thống cho ứng dụng, cơ sở dữ liệu, MCP listener, tác vụ, terminal, phê duyệt và trạng thái client. - Task rail hiểu project, có search, pagination, rename, delete, unread counter và nhóm theo workspace. - Task timeline giàu thông tin với Markdown, tool output, syntax highlighting, file-change summary, side-by-side diff, trạng thái sub-agent và stop control. - Hàng đợi approval cho conversation, activity và plan question. @@ -132,11 +132,11 @@ Compact & resume được thiết kế theo nguyên tắc fail-closed: nếu Cha Package `chatgpt-extension/` tùy chọn có thể sử dụng tab `chatgpt.com` đã đăng nhập sẵn để: -- bắt đầu hoặc tiếp tục browser conversation từ ChatCMD; +- bắt đầu hoặc tiếp tục hội thoại trên trình duyệt từ ChatCMD; - chọn model label đang hiển thị trên ChatGPT; - queue, reorder, edit, gửi ngay hoặc xóa follow-up message; - dừng generation đang chạy; -- relay final response và conversation identity về local task; +- chuyển tiếp phản hồi cuối và danh tính hội thoại về tác vụ cục bộ; - hiển thị approval cho local conversation, tool và plan question ngay trong ChatGPT; - cung cấp browser fallback cho công việc Sub-Agent. @@ -201,10 +201,10 @@ Sau đó mở . Vite proxy `/api` và `/ws` sang Rust ser ## Kết nối một MCP client -1. Mở **Plugin list** trong ChatCMD và chọn **Create new Plugin connection**. +1. Mở **Danh sách plugin** trong ChatCMD và chọn **Tạo kết nối plugin mới**. 2. Đặt cho profile một tên dễ nhận biết. 3. Chỉ chọn các nhóm tool mà client đó thực sự cần, sau đó lưu profile. -4. Với MCP client cục bộ, chọn **Create new access code** từ menu của profile và lưu endpoint dùng một lần ngay lập tức. +4. Với MCP client cục bộ, chọn **Tạo mã truy cập mới** từ menu của profile và lưu endpoint dùng một lần ngay lập tức. 5. Thêm URL đó vào client dưới dạng Streamable HTTP MCP server. Không cần header `Authorization`; secret chính là segment cuối của URL path. Để kết nối một AI chạy trên web qua public endpoint của riêng bạn, làm theo [docs/PLUGIN_SETUP.md](docs/PLUGIN_SETUP.md). Tài liệu này bao gồm cấu hình tunnel/reverse proxy, flow ChatGPT developer mode và cài đặt browser extension tùy chọn. @@ -283,8 +283,8 @@ Xem [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) để biết contributor workflow
- Task workspace và phản hồi cuối
- ChatCMD task workspace hiển thị phản hồi ChatGPT đã hoàn tất và chi tiết task + Không gian làm việc của tác vụ và phản hồi cuối
+ Không gian làm việc tác vụ ChatCMD hiển thị phản hồi ChatGPT đã hoàn tất và chi tiết tác vụ
Timeline hoạt động của agent
@@ -307,14 +307,14 @@ Xem [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) để biết contributor workflow Timeline ChatCMD hiển thị hai Sub-Agent task đã hoàn tất
- Dialog câu hỏi trong Plan mode
- Dialog câu hỏi Plan mode của ChatCMD với hai lựa chọn và tùy chọn câu trả lời tùy chỉnh + Hộp thoại câu hỏi trong chế độ lập kế hoạch
+ Hộp thoại câu hỏi ở chế độ lập kế hoạch của ChatCMD với hai lựa chọn và tùy chọn câu trả lời tùy chỉnh
- Task Plan mode và approval control
- ChatCMD task trong Plan mode với reasoning history và execution approval control + Tác vụ ở chế độ lập kế hoạch và điều khiển phê duyệt
+ Tác vụ ChatCMD ở chế độ lập kế hoạch với lịch sử suy luận và điều khiển phê duyệt thực thi
Phản hồi plan đã hoàn tất
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() { diff --git a/web/src/chatgpt/ChatGptConversation.tsx b/web/src/chatgpt/ChatGptConversation.tsx index 0bcac141..e3be9e4d 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); @@ -78,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); } }; @@ -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 }); @@ -151,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}
}
@@ -164,12 +173,12 @@ export function NewChatGptConversation() { {enabledAgents.map((agent) => )}
- Thư mục dự án + {tr('Project folder')}
- - {projectFolder && } + {projectFolder && }
@@ -182,22 +191,22 @@ export function NewChatGptConversation() {
- {textAttachments.length > 0 &&
- {textAttachments.map((attachment) => {attachment.name})} + {textAttachments.length > 0 &&
+ {textAttachments.map((attachment) => {attachment.name})}
}