diff --git a/package.json b/package.json index 4acaf3de3..00c56a2d8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codeg", "private": true, - "version": "0.21.5", + "version": "0.21.6", "packageManager": "pnpm@11.9.0", "scripts": { "dev": "next dev --turbopack", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 850c7c97d..1bda9cdc9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1014,7 +1014,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "codeg" -version = "0.21.5" +version = "0.21.6" dependencies = [ "aes-gcm", "agent-client-protocol-schema", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fd514a82b..45304bc0f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codeg" -version = "0.21.5" +version = "0.21.6" description = "Agent Code Generation App" authors = ["feitao"] edition = "2021" diff --git a/src-tauri/src/acp/delegation/tool_schema.json b/src-tauri/src/acp/delegation/tool_schema.json index d40a41731..1bfe5485c 100644 --- a/src-tauri/src/acp/delegation/tool_schema.json +++ b/src-tauri/src/acp/delegation/tool_schema.json @@ -1,7 +1,7 @@ [ { "name": "delegate_to_agent", - "description": "Hand off a self-contained sub-task to a separate local AI agent that runs in its own session. ASYNCHRONOUS: returns a task_id right away and the sub-agent keeps working in the background — this call never blocks. So you can fan out several delegations at once and keep working, then collect the results with get_delegation_status by passing the task_ids array (one id to poll a single task, or many at once to poll the whole fan-out in one call) — or stop one early with cancel_delegation(task_id). The sub-agent CANNOT see this conversation, your open files, or earlier turns — it starts cold, so `task` must carry everything it needs. Best for independent, parallelizable work you can describe up front; not for steps that need your ongoing back-and-forth.", + "description": "Hand off a self-contained sub-task to a separate local AI agent that runs in its own session. ASYNCHRONOUS: returns a task_id right away and the sub-agent keeps working in the background — this call never blocks. So you can fan out several delegations at once and keep working, then collect the results with get_delegation_status by passing the task_ids array (one id to poll a single task, or many at once to poll the whole fan-out in one call) — or stop one early with cancel_delegation(task_id). The sub-agent CANNOT see this conversation, your open files, or earlier turns — it starts cold, so `task` must carry everything it needs. Best for independent, parallelizable work you can describe up front; not for steps that need your ongoing back-and-forth. RECOGNIZING AN EXPLICIT DELEGATION REQUEST: the user can name a sub-agent directly in their message — it appears as `@AgentName` or as a Markdown link `[@AgentName](codeg://agent/)`. Such a mention IS an explicit instruction to delegate the associated work to that agent, even when the user never names this tool. If the message names several agents, make one call per agent, each carrying that agent's slice of the work.", "inputSchema": { "type": "object", "required": ["agent_type", "task"], @@ -22,7 +22,7 @@ "grok", "cursor" ], - "description": "Which local agent runs the sub-task. Pick the one best suited to the work." + "description": "Which local agent runs the sub-task. Pick the one best suited to the work — or, when the user's message names an agent via `@AgentName` / `codeg://agent/`, use that exact `` slug (e.g. `codeg://agent/claude_code` means `claude_code`)." }, "task": { "type": "string", diff --git a/src-tauri/src/acp/registry.rs b/src-tauri/src/acp/registry.rs index f2a5c9df0..324d28cbb 100644 --- a/src-tauri/src/acp/registry.rs +++ b/src-tauri/src/acp/registry.rs @@ -194,8 +194,8 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { name: "Claude Code", description: "ACP wrapper for Anthropic's Claude", distribution: AgentDistribution::Npx { - version: "0.60.0", - package: "@agentclientprotocol/claude-agent-acp@0.60.0", + version: "0.61.0", + package: "@agentclientprotocol/claude-agent-acp@0.61.0", cmd: "claude-agent-acp", args: &[], env: &[], @@ -231,8 +231,8 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { name: "Gemini CLI", description: "Google's official CLI for Gemini", distribution: AgentDistribution::Npx { - version: "0.51.0", - package: "@google/gemini-cli@0.51.0", + version: "0.52.0", + package: "@google/gemini-cli@0.52.0", cmd: "gemini", args: &["--acp", "--skip-trust"], env: &[], @@ -336,8 +336,8 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { name: "CodeBuddy", description: "Tencent Cloud's official AI coding assistant (ACP)", distribution: AgentDistribution::Npx { - version: "2.125.0", - package: "@tencent-ai/codebuddy-code@2.125.0", + version: "2.126.0", + package: "@tencent-ai/codebuddy-code@2.126.0", cmd: "codebuddy", args: &["--acp"], env: &[], @@ -350,8 +350,8 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { name: "Kimi Code", description: "Moonshot AI's official CLI coding assistant (ACP)", distribution: AgentDistribution::Npx { - version: "0.28.1", - package: "@moonshot-ai/kimi-code@0.28.1", + version: "0.29.0", + package: "@moonshot-ai/kimi-code@0.29.0", cmd: "kimi", args: &["acp"], env: &[], @@ -407,8 +407,8 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { // leading `KEY=value` argv and sacp's `parse_env_var` only accepts // `[A-Za-z0-9_]` env names, which npm's `@scope:registry` key is not.) distribution: AgentDistribution::Npx { - version: "0.2.103", - package: "@xai-official/grok@0.2.103", + version: "0.2.111", + package: "@xai-official/grok@0.2.111", cmd: "grok", // Only the ACP subcommand lives here. Grok's ROOT-level launch // flags (`--no-auto-update` always, `--permission-mode ` @@ -419,7 +419,7 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { // args rather than appending after. args: &["agent", "stdio"], env: &[], - // `@xai-official/grok@0.2.103` declares `engines.node: ">=20"`; + // `@xai-official/grok@0.2.111` declares `engines.node: ">=20"`; // surface that in preflight so Node 18 isn't silently accepted. node_required: Some("20.0.0"), }, @@ -441,34 +441,34 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { // (downloads.cursor.com/lab////...); custom // versions substitute into the same pattern. distribution: AgentDistribution::Binary { - version: "2026.07.16-899851b", + version: "2026.07.20-8cc9c0b", cmd: "cursor-agent", args: &["acp"], env: &[], platforms: &[ PlatformBinary { platform: "darwin-aarch64", - url: "https://downloads.cursor.com/lab/2026.07.16-899851b/darwin/arm64/agent-cli-package.tar.gz", + url: "https://downloads.cursor.com/lab/2026.07.20-8cc9c0b/darwin/arm64/agent-cli-package.tar.gz", }, PlatformBinary { platform: "darwin-x86_64", - url: "https://downloads.cursor.com/lab/2026.07.16-899851b/darwin/x64/agent-cli-package.tar.gz", + url: "https://downloads.cursor.com/lab/2026.07.20-8cc9c0b/darwin/x64/agent-cli-package.tar.gz", }, PlatformBinary { platform: "linux-aarch64", - url: "https://downloads.cursor.com/lab/2026.07.16-899851b/linux/arm64/agent-cli-package.tar.gz", + url: "https://downloads.cursor.com/lab/2026.07.20-8cc9c0b/linux/arm64/agent-cli-package.tar.gz", }, PlatformBinary { platform: "linux-x86_64", - url: "https://downloads.cursor.com/lab/2026.07.16-899851b/linux/x64/agent-cli-package.tar.gz", + url: "https://downloads.cursor.com/lab/2026.07.20-8cc9c0b/linux/x64/agent-cli-package.tar.gz", }, PlatformBinary { platform: "windows-aarch64", - url: "https://downloads.cursor.com/lab/2026.07.16-899851b/windows/arm64/agent-cli-package.zip", + url: "https://downloads.cursor.com/lab/2026.07.20-8cc9c0b/windows/arm64/agent-cli-package.zip", }, PlatformBinary { platform: "windows-x86_64", - url: "https://downloads.cursor.com/lab/2026.07.16-899851b/windows/x64/agent-cli-package.zip", + url: "https://downloads.cursor.com/lab/2026.07.20-8cc9c0b/windows/x64/agent-cli-package.zip", }, ], dir_entry: Some(BinaryDirEntry { @@ -572,8 +572,8 @@ mod tests { let meta = get_agent_meta(AgentType::Cursor); assert_binary_version( AgentType::Cursor, - "2026.07.16-899851b", - "/lab/2026.07.16-899851b/", + "2026.07.20-8cc9c0b", + "/lab/2026.07.20-8cc9c0b/", ); match meta.distribution { AgentDistribution::Binary { @@ -603,14 +603,14 @@ mod tests { fn registry_pins_current_acp_agent_versions() { assert_npx_version( AgentType::ClaudeCode, - "0.60.0", - "@agentclientprotocol/claude-agent-acp@0.60.0", + "0.61.0", + "@agentclientprotocol/claude-agent-acp@0.61.0", Some("22.0.0"), ); assert_npx_version( AgentType::Gemini, - "0.51.0", - "@google/gemini-cli@0.51.0", + "0.52.0", + "@google/gemini-cli@0.52.0", Some("20.0.0"), ); assert_npx_version( @@ -627,14 +627,14 @@ mod tests { ); assert_npx_version( AgentType::CodeBuddy, - "2.125.0", - "@tencent-ai/codebuddy-code@2.125.0", + "2.126.0", + "@tencent-ai/codebuddy-code@2.126.0", Some("22.0.0"), ); assert_npx_version( AgentType::KimiCode, - "0.28.1", - "@moonshot-ai/kimi-code@0.28.1", + "0.29.0", + "@moonshot-ai/kimi-code@0.29.0", Some("22.19.0"), ); assert_npx_version( @@ -646,8 +646,8 @@ mod tests { assert_npx_version(AgentType::Pi, "0.0.31", "pi-acp@0.0.31", Some("22.0.0")); assert_npx_version( AgentType::Grok, - "0.2.103", - "@xai-official/grok@0.2.103", + "0.2.111", + "@xai-official/grok@0.2.111", Some("20.0.0"), ); assert_binary_version(AgentType::OpenCode, "1.18.4", "/releases/download/v1.18.4/"); diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 166c62373..ca58260d0 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -604,7 +604,7 @@ async fn detect_local_version(agent_type: AgentType) -> Option { } // Dir-tree agents: a user-installed CLI (no codeg cache) still // reports a version via ` --version` (e.g. cursor-agent → - // "2026.07.16-899851b"). + // "2026.07.20-8cc9c0b"). if dir_entry.is_some() { return system_dir_agent_version(cmd).await; } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c8fd92048..dc52db76d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "codeg", - "version": "0.21.5", + "version": "0.21.6", "identifier": "app.codeg", "build": { "beforeDevCommand": "pnpm tauri:before-dev", diff --git a/src/components/message/virtualized-message-thread.test.tsx b/src/components/message/virtualized-message-thread.test.tsx new file mode 100644 index 000000000..82a53c623 --- /dev/null +++ b/src/components/message/virtualized-message-thread.test.tsx @@ -0,0 +1,125 @@ +import type { ReactNode } from "react" +import { fireEvent, render, screen } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const testState = vi.hoisted(() => ({ + scrollRef: { current: null as HTMLDivElement | null }, +})) + +vi.mock("use-stick-to-bottom", () => ({ + useStickToBottomContext: () => ({ scrollRef: testState.scrollRef }), +})) + +vi.mock("virtua", () => ({ + Virtualizer: ({ children }: { children: ReactNode }) => <>{children}, +})) + +vi.mock("@/components/ai-elements/message-thread", () => ({ + MessageThreadContent: ({ + children, + scrollClassName, + }: { + children: ReactNode + scrollClassName?: string + }) => ( +
{ + testState.scrollRef.current = element + }} + className={scrollClassName} + data-testid="viewport" + > + {children} +
+ ), +})) + +import { VirtualizedMessageThread } from "@/components/message/virtualized-message-thread" + +function renderThread( + content: ReactNode =
text
+) { + return render( + item.id} + renderItem={() => content} + /> + ) +} + +function pointerDown(element: HTMLElement, button: number) { + fireEvent(element, new MouseEvent("pointerdown", { bubbles: true, button })) +} + +function keyDown(element: HTMLElement, key: string) { + fireEvent.keyDown(element, { key }) +} + +beforeEach(() => { + testState.scrollRef.current = null +}) + +describe("VirtualizedMessageThread focus origin", () => { + it("marks pointer-origin focus and clears it on blur", () => { + renderThread() + const viewport = screen.getByTestId("viewport") + + pointerDown(screen.getByTestId("content"), 0) + + expect(document.activeElement).toBe(viewport) + expect(viewport).toHaveAttribute("data-focus-origin", "pointer") + expect(viewport.className).toContain( + "data-[focus-origin=pointer]:focus-visible:ring-0" + ) + + fireEvent.blur(viewport) + expect(viewport).not.toHaveAttribute("data-focus-origin") + }) + + it("clears the pointer marker on keyboard input so the ring returns", () => { + renderThread() + const viewport = screen.getByTestId("viewport") + + pointerDown(screen.getByTestId("content"), 0) + expect(viewport).toHaveAttribute("data-focus-origin", "pointer") + + // Switching to keyboard scrolling drops the marker, so the suppressing + // `data-[focus-origin=pointer]` selector no longer matches and the + // keyboard focus ring becomes visible again. + keyDown(viewport, "ArrowDown") + expect(viewport).not.toHaveAttribute("data-focus-origin") + expect(document.activeElement).toBe(viewport) + }) + + it("keeps keyboard-origin focus distinguishable", () => { + renderThread() + const viewport = screen.getByTestId("viewport") + + viewport.focus() + + expect(document.activeElement).toBe(viewport) + expect(viewport).not.toHaveAttribute("data-focus-origin") + expect(viewport.className).toContain("focus-visible:ring-2") + }) + + it("does not mark focus when an interactive control is clicked", () => { + renderThread() + const viewport = screen.getByTestId("viewport") + + pointerDown(screen.getByTestId("action"), 0) + + expect(viewport).not.toHaveAttribute("data-focus-origin") + expect(document.activeElement).not.toBe(viewport) + }) + + it("does not mark focus for a right click", () => { + renderThread() + const viewport = screen.getByTestId("viewport") + + pointerDown(screen.getByTestId("content"), 2) + + expect(viewport).not.toHaveAttribute("data-focus-origin") + expect(document.activeElement).not.toBe(viewport) + }) +}) diff --git a/src/components/message/virtualized-message-thread.tsx b/src/components/message/virtualized-message-thread.tsx index e1987203f..93c2a5aed 100644 --- a/src/components/message/virtualized-message-thread.tsx +++ b/src/components/message/virtualized-message-thread.tsx @@ -103,6 +103,9 @@ function VirtualizedMessageThreadImpl({ const el = scrollRef.current if (!el) return el.tabIndex = 0 + const clearPointerFocus = () => { + el.removeAttribute("data-focus-origin") + } const onPointerDown = (e: PointerEvent) => { // Ignore right-click and macOS ctrl-click (both open the context menu). if (e.button !== 0 || e.ctrlKey) return @@ -117,10 +120,23 @@ function VirtualizedMessageThreadImpl({ ) ) return + el.setAttribute("data-focus-origin", "pointer") el.focus({ preventScroll: true }) } el.addEventListener("pointerdown", onPointerDown) - return () => el.removeEventListener("pointerdown", onPointerDown) + el.addEventListener("blur", clearPointerFocus) + // Once the user drives the viewport with the keyboard (Arrow/Page/Home/End + // to scroll), drop the pointer-origin marker so the focus ring reappears — + // keeping the keyboard focus indicator visible per WCAG 2.4.7. The ring is + // only suppressed for the mouse click that focused the viewport, not for + // subsequent keyboard use. + el.addEventListener("keydown", clearPointerFocus) + return () => { + el.removeEventListener("pointerdown", onPointerDown) + el.removeEventListener("blur", clearPointerFocus) + el.removeEventListener("keydown", clearPointerFocus) + clearPointerFocus() + } }, [scrollRef]) // Pre-compute the three possible padding styles so every render reuses @@ -146,7 +162,7 @@ function VirtualizedMessageThreadImpl({ {items.length === 0 ? (