From a813de385affd700f0cf8dc5ff64fcdd20122d14 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 20 Jul 2026 15:25:33 +0800 Subject: [PATCH 1/9] feat(workspace): show the remote workspace name in the status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a remote-desktop window, the status bar now shows the connected workspace's name — with its address in a hover tooltip — to the left of the session count. The agent icon list that sat to the right of the count is removed. --- src/components/layout/status-bar-stats.tsx | 102 +++++++++++++-------- 1 file changed, 63 insertions(+), 39 deletions(-) diff --git a/src/components/layout/status-bar-stats.tsx b/src/components/layout/status-bar-stats.tsx index 91cce94ec..427ad9cf0 100644 --- a/src/components/layout/status-bar-stats.tsx +++ b/src/components/layout/status-bar-stats.tsx @@ -1,68 +1,92 @@ "use client" import { useMemo } from "react" -import { BarChart3 } from "lucide-react" +import { BarChart3, MonitorCloud } from "lucide-react" import { useTranslations } from "next-intl" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { AGENT_LABELS } from "@/lib/types" import { AgentIcon } from "@/components/agent-icon" +import { useRemoteConnection } from "@/contexts/remote-connection-context" import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" export function StatusBarStats() { const t = useTranslations("Folder.statusBar.stats") const stats = useAppWorkspaceStore((s) => s.stats) + // Non-null only in a remote-desktop window (a Tauri client bound to a remote + // codeg-server); local windows have no RemoteConnection in context. + const remoteConnection = useRemoteConnection()?.connection ?? null const activeAgents = useMemo( () => stats?.by_agent.filter((a) => a.conversation_count > 0) ?? [], [stats] ) - if (!stats) return null + if (!remoteConnection && !stats) return null return ( - - - - - -
- {t("summary", { - conversations: stats.total_conversations, - messages: stats.total_messages, - })} -
-
- {activeAgents.map((a) => ( -
- - - {AGENT_LABELS[a.agent_type]} +
+ {remoteConnection && ( + + + + + + {remoteConnection.name} - - {a.conversation_count} + + +

{remoteConnection.name}

+

{remoteConnection.base_url}

+
+
+
+ )} + {stats && ( + + + + + +
+ {t("summary", { + conversations: stats.total_conversations, + messages: stats.total_messages, + })} +
+
+ {activeAgents.map((a) => ( +
+ + + {AGENT_LABELS[a.agent_type]} + + + {a.conversation_count} + +
+ ))}
- ))} -
- - + + + )} +
) } From de53f8c226f7b5e69f7101e7bdc73a69cd68b873 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 20 Jul 2026 16:59:24 +0800 Subject: [PATCH 2/9] feat(workspace): polish the background-image appearance and titlebar chrome When a workspace background image is enabled, render inline code, user-message bubbles, delegation cards, and the Monaco sticky-scroll header as translucent frosted surfaces so they blend into the image instead of appearing as opaque blocks. Draw the browser-style titlebar tabs at equal width with a continuous archway bottom border under the active tab, and scale the window-chrome reserve with the app zoom level so the top-right controls no longer overflow at high zoom. Fix the delegation card rendering a blank icon for the Cursor and Grok sub-agent types. --- src/app/globals.css | 129 ++++++++++++++++-- src/app/workspace/layout.tsx | 10 +- src/components/ai-elements/message.tsx | 8 +- .../files/file-workspace-tab-bar.tsx | 32 +++-- src/components/layout/aux-panel.tsx | 4 +- src/components/layout/left-edge-chrome.tsx | 4 +- src/components/layout/right-edge-chrome.tsx | 6 +- src/components/layout/sidebar.test.tsx | 3 + src/components/layout/sidebar.tsx | 7 +- .../message/delegated-sub-thread.tsx | 2 +- .../message/delegation-status-card.tsx | 2 +- .../message/delegation-status-group-card.tsx | 2 +- src/components/tabs/tab-bar.tsx | 19 ++- src/components/tabs/tab-item.tsx | 25 ++-- src/lib/delegation-card.test.ts | 11 ++ src/lib/delegation-card.ts | 22 ++- src/lib/monaco-themes.test.ts | 41 ++++++ src/lib/monaco-themes.ts | 23 +++- src/lib/window-chrome.test.ts | 50 +++++++ src/lib/window-chrome.ts | 50 ++++++- 20 files changed, 370 insertions(+), 80 deletions(-) create mode 100644 src/lib/window-chrome.test.ts diff --git a/src/app/globals.css b/src/app/globals.css index 1d7f22979..08b12f959 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -36,6 +36,11 @@ --ws-surface-alpha 由 inline 脚本 / AppearanceProvider 覆盖为用户设置。 */ --ws-surface-alpha: 0.3; --ws-surface-blur: 8px; + + /* 消息流里的内联代码 / 用户气泡 / 委派卡片的固定半透明度:刻意不随「面板不透明度」滑块 + (--ws-surface-alpha)变化,让这些消息内小面在任何面板透明度下都稳定。配 backdrop 磨砂 + + 1px 描边(见下方各规则),在忙碌背景图上仍看得清、分得出边界。 */ + --ws-capsule-alpha: 0.6; } /* =========================================================================== @@ -1080,6 +1085,56 @@ backdrop-filter: blur(var(--ws-surface-blur)); } +/* 会话内联代码(Streamdown 的 inline-code,元素自带 bg-muted):开图转半透明磨砂。忙碌背景 + 图上单靠半透明色块看不清、看不出边界(用户反馈),故 backdrop 磨砂把身后背景糊开、与之 + 分离,再用 inset 1px ring 描出边界(ring 用纯色 var(--border),不改盒模型、无回流)。组合式 + 无 base 规则,关图零回归(bg-muted 原样保留)。代码块(code-block-body)有 shiki 底色,不在此列。 */ +[data-workspace-bg="on"] [data-streamdown="inline-code"] { + background-color: color-mix( + in oklch, + var(--muted) calc(var(--ws-capsule-alpha) * 100%), + transparent + ); + box-shadow: inset 0 0 0 1px var(--border); + -webkit-backdrop-filter: blur(var(--ws-surface-blur)); + backdrop-filter: blur(var(--ws-surface-blur)); + /* inline-code 是行内元素,跨行折断时默认(slice)会把底色/ring/圆角当一个盒切开——每段行 + 片只画到断点,ring 在断处豁口。clone 让每个行片各自成完整圆角 chip(自带底色 + ring), + 折行时也不露豁口。仅 bg-on 生效,关图零回归。 */ + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} + +/* 用户消息气泡(MessageContent 上的 ws-surface-secondary,配对元素自带的 + group-[.is-user]:bg-secondary):开图转半透明磨砂。忙碌背景图上单靠半透明看不清、看不出 + 边界(用户反馈),故加 backdrop 磨砂(与背景分离)+ inset 1px ring 描边。以 .is-user 祖先 + 限定——助手消息(.is-assistant)本无气泡底色,须保持画布透出,不受影响。组合式无 base 规则,关图零回归。 */ +[data-workspace-bg="on"] .is-user .ws-surface-secondary { + background-color: color-mix( + in oklch, + var(--secondary) calc(var(--ws-capsule-alpha) * 100%), + transparent + ); + box-shadow: inset 0 0 0 1px var(--border); + -webkit-backdrop-filter: blur(var(--ws-surface-blur)); + backdrop-filter: blur(var(--ws-surface-blur)); +} + +/* 消息流委派卡片(delegate_to_agent 的卡片,元素自带 bg-card + border-border):开图转半透明 + 磨砂——backdrop 磨砂把身后背景糊开、与之分离(卡片自带 border 已描边界,无需 ring)。alpha + 固定(--ws-capsule-alpha),与消息里的内联代码 / 气泡统一,区别于面板级 ws-surface-card 的 + 滑块磨砂玻璃。组合式无 base 规则,关图零回归(bg-card 原样保留)。仅委派卡片用它;提问卡片 / + git-log 面板仍用 ws-surface-card。 */ +[data-workspace-bg="on"] .ws-surface-capsule { + background-color: color-mix( + in oklch, + var(--card) calc(var(--ws-capsule-alpha) * 100%), + transparent + ); + -webkit-backdrop-filter: blur(var(--ws-surface-blur)); + backdrop-filter: blur(var(--ws-surface-blur)); +} + /* Canvas 表面:开启背景图时强制透明,让底层背景图 + 遮罩直接透出(会话/文件 overlay section、侧栏会话行)。无 base 规则 —— 与元素上配对的 bg-*(bg-background / bg-sidebar)组合,未启用背景图时完全等价那个纯色(零回归)。透明不依赖 @@ -1099,6 +1154,34 @@ background-color: transparent; } +/* Monaco sticky scroll(钉住的父级作用域行):开背景图时编辑器画布透明(WSBG_CANVAS_ALPHA=0), + sticky 头部也随画布透明(monaco-themes.ts 里 sticky 背景跟随 canvas)。放任透明则滚动的代码 + 从钉住行下方透出、两者重叠看不清;填成不透明画布色又是浮在背景图上一坨突兀的深色块(用户 + 反馈「一坨黑色」)。这里把整条 .sticky-widget 做成与消息内联代码/气泡同族的磨砂表面:半透明 + 画布色(--card,与编辑器画布同源)+ backdrop 磨砂,把身后滚动代码与背景图糊开,钉住行文字 + 清晰浮于其上。alpha 固定(--ws-capsule-alpha),与消息内小面统一、不随面板滑块。主题侧 sticky + 背景已随画布透明,故此处单层着色、无接缝。关图无 data-workspace-bg,规则不生效,零回归。 + + ★覆盖到滚动条条带:Monaco 运行时给 .sticky-widget 打 inline width = + layoutInfo.width − verticalScrollbarWidth(stickyScrollWidget.js:160,即内容宽,右侧漏出 + 一条竖向滚动条宽的条带),CSS 里的 width:100% 被这条 inline 盖过 → 磨砂层到不了右边缘, + 滚动代码从那条缝透出(用户反馈「右边显示不全」)。故用 width:100% !important 压过 inline + width,把磨砂层铺到编辑器右缘补上那条缝。安全性:可见滚动条 .monaco-scrollable-element>.visible + 是 z-index:11(scrollbars.css),高于 sticky-widget 的 z-index:4——磨砂层永远在滚动条之下, + 滑块照常浮在其上、可拖可点;滚动条隐藏时(opacity:0 不绘制)磨砂层正好补上条带。backdrop + 只糊身后的代码,不糊上层滑块。 */ +[data-workspace-bg="on"] .monaco-editor .sticky-widget { + /* 压过 Monaco 的 inline 内容宽,铺满到右缘(含滚动条条带)。见上方安全性说明。 */ + width: 100% !important; + background-color: color-mix( + in oklch, + var(--card) calc(var(--ws-capsule-alpha) * 100%), + transparent + ); + -webkit-backdrop-filter: blur(var(--ws-surface-blur)); + backdrop-filter: blur(var(--ws-surface-blur)); +} + /* 顶部标签条「浏览器拱门」下边框(组合式,无 base 规则,关图零回归)。整条标签条 + 所有 tab 开图皆透明(透出真背景图);靠一条细下边框把标签条与下方内容分隔,唯独激活 tab 处开口——线绕到激活 tab 顶部走一圈(拱门轮廓),激活 tab 靠这圈描边区分而非填充色。 @@ -1115,10 +1198,23 @@ /* 旧 WebKitGTK 无 color-mix 时降级为不透明(背景图仅在透明内容区透出,不崩)。 */ @supports not (background: color-mix(in oklch, white, transparent)) { + /* 老引擎关掉背景模糊(背景色 color-mix 已作废回落到不透明 bg-*)。内联代码 / 用户气泡的 + inset ring 用纯色 var(--border),不依赖 color-mix,照常描边(委派卡片自带 border 同理)。 */ [data-workspace-bg="on"] .ws-surface, [data-workspace-bg="on"] .ws-surface-muted, [data-workspace-bg="on"] .ws-surface-sidebar, - [data-workspace-bg="on"] .ws-surface-card { + [data-workspace-bg="on"] .ws-surface-card, + [data-workspace-bg="on"] [data-streamdown="inline-code"], + [data-workspace-bg="on"] .is-user .ws-surface-secondary, + [data-workspace-bg="on"] .ws-surface-capsule { + -webkit-backdrop-filter: none; + backdrop-filter: none; + } + /* Monaco sticky scroll 是第三方 DOM,无自带 bg 工具类可回落——color-mix 作废后 + background-color 声明整条失效会让 .sticky-widget 透明(滚动代码透出)。故老引擎显式 + 回落到不透明画布色 var(--card)(即背景图开启前的老不透明band,仍可读),并关掉磨砂。 */ + [data-workspace-bg="on"] .monaco-editor .sticky-widget { + background-color: var(--card); -webkit-backdrop-filter: none; backdrop-filter: none; } @@ -1905,13 +2001,13 @@ /* The active tab's close button is always visible (an absolute `right-2` / `w-4` overlay). Reserve its footprint (0.5rem offset + 1rem width = 1.5rem) as the - content's right padding, overriding the default `px-2` right. The tab is - content-sized (`grow-0 basis-auto`), so this widens it just enough to fit the FULL - title AND the button side by side — the fix for an only/short active tab whose - title used to sit under the button and lose its tail to the fade. Non-active tabs - don't reserve it: their button only appears on hover, where the label widens its - fade to clear the button (above); idle titles keep the full width (R40 "收回按钮 - 空间") and tabs never reflow-widen on hover. General (not workspace-bg gated). */ + content's right padding, overriding the default `px-2` right, so the title ends + left of the button (its tail fades under the reserved zone) instead of sitting + under it. Tabs are fixed-width (`grow-0 basis-48`); this padding just carves out + the button's column. Non-active tabs don't reserve it: their button only appears + on hover, where the label widens its fade to clear the button (above); idle + titles keep the full width and tabs never reflow-widen on hover. General (not + workspace-bg gated). */ .browser-tab-item[data-active="true"] .browser-tab-content { padding-right: 1.5rem; } @@ -2060,13 +2156,17 @@ overflow-hidden,脚可外飘)。top:0 = group pt-1.5 下沿(y6),bottom:0.5rem → 竖边止于 y32,正好接上 seat 的反向圆角脚(y32→40);竖边不再越过脚部下探到 y40,消除脚部两侧的 「垂直角边框」毛刺。rounded-t 出拱顶。仅 bg-on + 激活生效,关图无 ::after(零回归)。 - 取代旧的 .ws-tab-arch(那是画在内容 div 上的整高边框,底部越界成毛刺)。 */ + ★left/right:-1px(而非 0):竖边描边画在盒内沿,若贴 tab 边(left:0)则落在 x∈[0,1](tab + 内侧),而 seat 脚的竖直切线描边在 tab 外沿 x∈[-1,0](脚 ::before/::after 往 gutter 外 + 飘 0.5rem,border 画在其内沿)——两者差 1px 形成 y32 处「水平错位」,正是用户反馈激活 tab + 底部两角弧度「断断续续」的成因。把两侧各外移 1px → 竖边与脚竖直切线共线(x∈[-1,0] / + [w,w+1]),拱门侧边与反向弧脚无缝相接。取代旧的 .ws-tab-arch(整高边框底部越界成毛刺)。 */ [data-workspace-bg="on"] .browser-tab-item[data-active="true"]::after { content: ""; position: absolute; top: 0; - left: 0; - right: 0; + left: -1px; + right: -1px; bottom: 0.5rem; border-top: 1px solid var(--border); border-left: 1px solid var(--border); @@ -2116,7 +2216,12 @@ [data-workspace-bg="on"] .browser-tab-item[data-adjacent-active] .ws-strip-line { - border-bottom-color: transparent; + /* border-bottom-WIDTH:0(而非仅 color:transparent):内缩基线走 ::after(bottom:0),其 + containing block 是本元素 padding box。若保留 1px 透明边框,padding box 比 border box + 矮 1px → ::after 落在 border box 底沿上方 1px,与其它 tab 的 border-bottom(落在 border + box 底沿)差 1px,即用户反馈「紧邻激活的 tab 下边框对不齐」。撤掉边框宽度 → padding box + 与 border box 同底,::after bottom:0 正落 border box 底沿,与普通 tab 基线共线。 */ + border-bottom-width: 0; } [data-workspace-bg="on"] .browser-tab-item[data-adjacent-active] diff --git a/src/app/workspace/layout.tsx b/src/app/workspace/layout.tsx index ccb108420..db5c7d082 100644 --- a/src/app/workspace/layout.tsx +++ b/src/app/workspace/layout.tsx @@ -52,7 +52,7 @@ import { } from "@/contexts/workspace-context" import { RemoteConnectionGate } from "@/contexts/remote-connection-context" import { UpdateProvider } from "@/components/providers/update-provider" -import { useWorkspaceBackground } from "@/hooks/use-appearance" +import { useWorkspaceBackground, useZoomLevel } from "@/hooks/use-appearance" import { FILL_MODE_STYLE } from "@/lib/workspace-background" import { TabBar } from "@/components/tabs/tab-bar" import { TerminalPanel } from "@/components/terminal/terminal-panel" @@ -264,14 +264,16 @@ function WorkspaceContent({ children }: { children: React.ReactNode }) { const { isOpen: sidebarOpen } = useSidebarContext() const { isOpen: auxOpen } = useAuxPanelContext() const { isMac, isWindows, isLinux } = usePlatform() + const { zoomLevel } = useZoomLevel() const hasConvTabs = useTabStore((s) => s.tabs.length > 0) const winLinuxControls = isDesktop() && (isWindows || isLinux) // The window chrome (toggle/remote left, terminal/aux/settings right) now // lives in fixed corner overlays (see FolderLayoutShell) that never move on // panel toggles. Each edge column just reserves the overlay's width so its - // tabs never render underneath. - const leftReserve = leftChromeReserve(isMac && isDesktop()) - const rightReserve = rightChromeReserve(winLinuxControls) + // tabs never render underneath. The reserve scales with the app zoom so it + // tracks the rem-sized overlay buttons (which grow with zoom). + const leftReserve = leftChromeReserve(isMac && isDesktop(), zoomLevel) + const rightReserve = rightChromeReserve(winLinuxControls, zoomLevel) // A middle column reserves the right overlay only when it (not the aux panel) // is the window's right edge: the file column in fusion, else conversation. const convReservesRight = !auxOpen && mode === "conversation" diff --git a/src/components/ai-elements/message.tsx b/src/components/ai-elements/message.tsx index 042ede040..0d4b84fff 100644 --- a/src/components/ai-elements/message.tsx +++ b/src/components/ai-elements/message.tsx @@ -60,7 +60,13 @@ export const MessageContent = ({
-
+ {/* Drag spacer, floored at `min-w-10` (40px) instead of `min-w-0`: even + when many tabs overflow and squeeze this region, a grabbable + window-drag gap always remains between the last tab and the + maximize button, so the tabs never butt against the button and the + packed title bar stays draggable. */} +
{mode === "fusion" && ( -
+ {/* Drag spacer, floored at `min-w-10` (40px) instead of `min-w-0`: even + when many tabs overflow and squeeze this region, a grabbable + window-drag gap always remains to the RIGHT of the new-conversation + button, so the button never reaches the strip's right edge and the + packed title bar stays draggable. */} +
) diff --git a/src/components/tabs/tab-item.tsx b/src/components/tabs/tab-item.tsx index 96800e5bd..1474657d9 100644 --- a/src/components/tabs/tab-item.tsx +++ b/src/components/tabs/tab-item.tsx @@ -128,19 +128,20 @@ export const TabItem = memo(function TabItem({ data-adjacent-active={embedded ? adjacentActive : undefined} className={cn( "cursor-grab active:cursor-grabbing", - // Embedded (browser-style): each tab sizes to its content (`basis-auto`) - // up to `max-w-[15rem]`, so a few tabs sit at their natural width and the - // new-conversation button hugs the last one — the leftover row stays a - // window-drag region. `grow-0` keeps them from stretching to fill; they - // still `shrink` together (down to `min-w-0`, the label truncates) once - // the row fills. `browser-tab-item` draws the left-edge hairline - // separator (globals.css) as a 1px divider at each shared edge; tabs sit - // flush (no gutter) so the line is the only separation, and the inner row - // owns its own `overflow-hidden`. The active tab is raised (`z-10`) so - // its reverse-corner seat is never covered by a hovered neighbour's flare. - // Standalone: rounded pill, intrinsic size + horizontal scroll (mobile). + // Embedded (browser-style): every tab is EQUAL width (`basis-48` = 12rem, + // `grow-0` so they don't stretch to fill), so a long title and a short one + // read uniform instead of one wide / one narrow. They still `shrink` + // together (down to `min-w-0`, the label fades) once the row fills; above + // that the fixed basis keeps them equal. The new-conversation button hugs + // the last tab and the leftover row stays a window-drag region. + // `browser-tab-item` draws the left-edge hairline separator (globals.css) + // as a 1px divider at each shared edge; tabs sit flush (no gutter) so the + // line is the only separation, and the inner row owns its own + // `overflow-hidden`. The active tab is raised (`z-10`) so its reverse-corner + // seat is never covered by a hovered neighbour's flare. Standalone: rounded + // pill, intrinsic size + horizontal scroll (mobile). embedded - ? "browser-tab-item min-w-0 grow-0 shrink basis-auto max-w-[15rem] data-[active=true]:z-10" + ? "browser-tab-item min-w-0 grow-0 shrink basis-48 data-[active=true]:z-10" : "rounded-full shrink-0", !isCoarsePointer && (embedded diff --git a/src/lib/delegation-card.test.ts b/src/lib/delegation-card.test.ts index 00e3e575f..d04461633 100644 --- a/src/lib/delegation-card.test.ts +++ b/src/lib/delegation-card.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest" +import { ALL_AGENT_TYPES } from "@/lib/types" import { parseDelegationMeta, parseInput } from "./delegation-card" describe("parseInput wrapper peeling", () => { @@ -37,6 +38,16 @@ describe("parseInput wrapper peeling", () => { expect(parsed.agentType).toBeNull() expect(parsed.task).toBeNull() }) + + // Guards the allowlist against drifting behind the canonical agent list — the + // regression that left `grok` and `cursor` delegation cards iconless. Every + // known agent must resolve so its sub-agent card shows the right icon/label. + it.each(ALL_AGENT_TYPES)("recognizes the %s agent_type", (agentType) => { + const parsed = parseInput( + JSON.stringify({ agent_type: agentType, task: "do the thing" }) + ) + expect(parsed.agentType).toBe(agentType) + }) }) describe("parseDelegationMeta task fields", () => { diff --git a/src/lib/delegation-card.ts b/src/lib/delegation-card.ts index 76a910afe..424d82a5c 100644 --- a/src/lib/delegation-card.ts +++ b/src/lib/delegation-card.ts @@ -11,7 +11,7 @@ */ import { extractEmbeddedJsonObject } from "@/lib/embedded-json" -import { type AgentType } from "@/lib/types" +import { ALL_AGENT_TYPES, type AgentType } from "@/lib/types" import { type DelegationBinding, type DelegationStatus, @@ -37,18 +37,14 @@ export type ParsedInput = { workingDir: string | null } -const KNOWN_AGENT_TYPES: ReadonlySet = new Set([ - "claude_code", - "codex", - "open_code", - "gemini", - "cline", - "open_claw", - "hermes", - "code_buddy", - "kimi_code", - "pi", -]) +// Derived from the canonical `ALL_AGENT_TYPES` so a newly added agent is +// recognized here automatically. A hand-maintained duplicate previously drifted +// (it omitted `grok` and `cursor`), so their delegation cards resolved +// `agentType: null` — rendering the blank "unknown sub-agent" avatar/label +// instead of the agent's icon. Keep this sourced from one place. +const KNOWN_AGENT_TYPES: ReadonlySet = new Set( + ALL_AGENT_TYPES +) export type ParsedMeta = { status: DelegationStatus diff --git a/src/lib/monaco-themes.test.ts b/src/lib/monaco-themes.test.ts index a53b4b0ee..8d6d6e7a4 100644 --- a/src/lib/monaco-themes.test.ts +++ b/src/lib/monaco-themes.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest" import { configureLanguageValidation, defineMonacoThemes, + defineWorkspaceBgTheme, EDITOR_CANVAS_BG, EDITOR_LINE_HIGHLIGHT, monacoThemeName, @@ -135,12 +136,52 @@ describe("configureLanguageValidation", () => { expect(call?.[1].colors["editorGutter.background"]).toBe( EDITOR_CANVAS_BG.blue.dark ) + // Sticky scroll (pinned parent-scope lines) tracks the canvas: in the opaque + // base theme it equals the canvas bg (unchanged). The workspace-bg variant + // makes it transparent and frosts the band in CSS instead (see the wsbg test + // below) — so it stops being a stark opaque slab over a background image. + expect(call?.[1].colors["editorStickyScroll.background"]).toBe( + EDITOR_CANVAS_BG.blue.dark + ) + expect(call?.[1].colors["editorStickyScrollGutter.background"]).toBe( + EDITOR_CANVAS_BG.blue.dark + ) // The current-line highlight follows the theme's tinted --muted, not a fixed // gray, so the focused line carries the accent hue. expect(call?.[1].colors["editor.lineHighlightBackground"]).toBe( EDITOR_LINE_HIGHLIGHT.blue.dark ) }) + + it("lets the sticky-scroll band go transparent with the canvas in the workspace-bg variant", () => { + // With a workspace background image the canvas is transparent (alpha 0) and + // the sticky band tracks it, rather than staying an opaque slab: a single + // full-width frosted surface is painted on `.sticky-widget` in CSS instead + // (covering the scrollbar strip Monaco's content-width inner layer leaves + // bare). So the sticky bg must carry the same transparent canvas value. + const { monaco } = makeMonaco() + const defineTheme = monaco.editor.defineTheme + const base = monacoThemeName("blue", true) + + const name = defineWorkspaceBgTheme( + monaco as unknown as Parameters[0], + base, + 0 + ) + + const call = defineTheme.mock.calls.find((c) => c[0] === name) + expect(call).toBeDefined() + // Fully transparent (`…dark` + "00"), matching `editor.background`, so the + // frosted CSS band shows through uniformly across content, gutter and strip. + const transparentCanvas = `${EDITOR_CANVAS_BG.blue.dark}00` + expect(call?.[1].colors["editor.background"]).toBe(transparentCanvas) + expect(call?.[1].colors["editorStickyScroll.background"]).toBe( + transparentCanvas + ) + expect(call?.[1].colors["editorStickyScrollGutter.background"]).toBe( + transparentCanvas + ) + }) }) // The focused line's highlight tracks each theme's `--muted` token so it follows diff --git a/src/lib/monaco-themes.ts b/src/lib/monaco-themes.ts index 9a0c9a64a..15d847f44 100644 --- a/src/lib/monaco-themes.ts +++ b/src/lib/monaco-themes.ts @@ -520,13 +520,29 @@ function withCanvasBackground( // the editor. Empty (default) keeps them fully opaque — unchanged behaviour. alphaHexSuffix = "" ): Record { - const bg = EDITOR_CANVAS_BG[color][dark ? "dark" : "light"] + alphaHexSuffix + const opaqueBg = EDITOR_CANVAS_BG[color][dark ? "dark" : "light"] + const bg = opaqueBg + alphaHexSuffix return { ...base, "editor.background": bg, "editorGutter.background": bg, "peekViewEditor.background": bg, "peekViewEditorGutter.background": bg, + // Sticky scroll (pinned parent-scope lines) defaults its background to + // `editor.background`; keep it tracking the canvas here (so it follows `bg`, + // not a fixed value). In the opaque base theme that's the solid canvas colour + // — unchanged. When a workspace background image makes the canvas translucent, + // a fully OPAQUE sticky band read as a stark dark slab floating over the image + // ("一坨黑色"), and Monaco's inner `.sticky-widget-lines-scrollable` only spans + // the content width — leaving the vertical-scrollbar strip bare so scrolled + // code bled through on the right. So we let the band go transparent with the + // canvas and instead paint ONE full-width frosted surface on `.sticky-widget` + // in CSS (globals.css `[data-workspace-bg="on"] .monaco-editor .sticky-widget`): + // a translucent tint + backdrop blur that covers the strip and blends the + // header into the frosted-panel aesthetic while keeping the pinned lines + // legible. `bg` = opaque canvas when off (zero regression), transparent when on. + "editorStickyScroll.background": bg, + "editorStickyScrollGutter.background": bg, "editor.lineHighlightBackground": EDITOR_LINE_HIGHLIGHT[color][dark ? "dark" : "light"], } @@ -678,8 +694,9 @@ const WSBG_CANVAS_ALPHA = 0 // Like `useMonacoThemeSync`, but when a workspace background image is enabled it // swaps in a fully transparent-canvas theme (see `WSBG_CANVAS_ALPHA`) so the code // area reads like the conversation canvas rather than a frosted panel. Disabled → -// the opaque base theme, byte-for-byte unchanged (zero regression). Shared by the -// file editor, diff viewer and merge editor. +// the opaque base theme, visually unchanged (zero regression: the sticky-scroll +// keys equal `editor.background` when opaque). Shared by the file editor, diff +// viewer and merge editor. // // The caller supplies the loaded monaco instance (from the editor's onMount, or // `useMonaco()` for conditionally-mounted editors) rather than this hook calling diff --git a/src/lib/window-chrome.test.ts b/src/lib/window-chrome.test.ts new file mode 100644 index 000000000..cd1537557 --- /dev/null +++ b/src/lib/window-chrome.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest" + +import { + LEFT_CHROME_CLUSTER, + MAC_TRAFFIC_LIGHT_INSET, + RIGHT_CHROME_CLUSTER, + WINDOW_CAPTION_WIDTH, + leftChromeReserve, + rightChromeClusterWidth, + rightChromeReserve, +} from "./window-chrome" + +// The app "zoom" scales the root font-size (rem), so the rem-sized chrome buttons +// grow with zoom. Their fixed-px containers must grow by the same factor or the +// buttons overflow/clip at high zoom (the 150% bug). These guard that only the +// DOM button CLUSTER scales, while the native insets (macOS traffic-light +// clearance, Windows/Linux caption strip) stay fixed. +describe("window-chrome zoom scaling", () => { + it("defaults to 100% (no scaling) and matches the pre-zoom baseline", () => { + // Baseline the aux-panel collapse test also hard-codes: 116 (mac/web) and + // 116 + 138 = 254 (win/linux caption reserved). + expect(rightChromeReserve(false)).toBe(RIGHT_CHROME_CLUSTER) + expect(rightChromeReserve(true)).toBe( + RIGHT_CHROME_CLUSTER + WINDOW_CAPTION_WIDTH + ) + expect(rightChromeClusterWidth()).toBe(RIGHT_CHROME_CLUSTER) + expect(leftChromeReserve(false)).toBe(LEFT_CHROME_CLUSTER) + expect(leftChromeReserve(true)).toBe( + MAC_TRAFFIC_LIGHT_INSET + LEFT_CHROME_CLUSTER + ) + }) + + it("scales only the button cluster at 150%, leaving native insets fixed", () => { + // 116 → 174, 80 → 120. + expect(rightChromeClusterWidth(150)).toBe(174) + expect(rightChromeReserve(false, 150)).toBe(174) + // Native caption strip stays 138. + expect(rightChromeReserve(true, 150)).toBe(174 + WINDOW_CAPTION_WIDTH) + // Native traffic-light inset stays 76; only the 80 cluster scales to 120. + expect(leftChromeReserve(false, 150)).toBe(120) + expect(leftChromeReserve(true, 150)).toBe(MAC_TRAFFIC_LIGHT_INSET + 120) + }) + + it("scales the cluster down below 100% too and rounds to whole pixels", () => { + // 116 * 0.9 = 104.4 → 104 (rounded). + expect(rightChromeClusterWidth(90)).toBe(104) + // 80 * 0.5 = 40, plus the fixed 76 inset. + expect(leftChromeReserve(true, 50)).toBe(MAC_TRAFFIC_LIGHT_INSET + 40) + }) +}) diff --git a/src/lib/window-chrome.ts b/src/lib/window-chrome.ts index cdf5141a5..79db57684 100644 --- a/src/lib/window-chrome.ts +++ b/src/lib/window-chrome.ts @@ -30,18 +30,56 @@ export const LEFT_CHROME_CLUSTER = 80 /** Right cluster: terminal + aux + settings (three icon buttons + padding). */ export const RIGHT_CHROME_CLUSTER = 116 +/** + * Scale a DOM button-cluster width by the app's rem-based zoom. + * + * The app "zoom" scales the root font-size (`documentElement.style.fontSize = + * 16 * zoom/100`, see AppearanceProvider), so the rem-sized chrome buttons + * (`h-6 w-6`, `gap-1`, `pl-3`/`pr-3`) grow with zoom. Their containers must grow + * by the same factor or the buttons overflow and get clipped at high zoom. The + * NATIVE insets do NOT scale this way — the macOS traffic lights keep a constant + * horizontal inset (only their Y shifts with zoom, see `traffic_light_position_at` + * in commands/windows.rs) and the Windows/Linux caption buttons are fixed 46px + * each — so callers add those separately, outside this helper. + */ +function scaleCluster(px: number, zoom: number): number { + return Math.round((px * zoom) / 100) +} + /** * Width the window's left-edge column reserves for the left overlay. - * `macInset` adds the traffic-light clearance (desktop macOS only). + * `macInset` adds the traffic-light clearance (desktop macOS only); `zoom` (a + * percent, default 100) scales the rem-sized button cluster to match the buttons + * inside it, while the native traffic-light inset stays fixed. */ -export function leftChromeReserve(macInset: boolean): number { - return (macInset ? MAC_TRAFFIC_LIGHT_INSET : 0) + LEFT_CHROME_CLUSTER +export function leftChromeReserve(macInset: boolean, zoom = 100): number { + return ( + (macInset ? MAC_TRAFFIC_LIGHT_INSET : 0) + + scaleCluster(LEFT_CHROME_CLUSTER, zoom) + ) } /** * Width the window's right-edge column reserves for the right overlay. - * `winLinuxCaption` adds the native caption-button strip (desktop Win/Linux). + * `winLinuxCaption` adds the native caption-button strip (desktop Win/Linux); + * `zoom` (a percent, default 100) scales the rem-sized button cluster, while the + * fixed native caption strip stays constant. + */ +export function rightChromeReserve( + winLinuxCaption: boolean, + zoom = 100 +): number { + return ( + scaleCluster(RIGHT_CHROME_CLUSTER, zoom) + + (winLinuxCaption ? WINDOW_CAPTION_WIDTH : 0) + ) +} + +/** + * The right-edge overlay's OWN width — just the (zoom-scaled) button cluster. + * The native caption strip isn't part of this box; it's cleared by the overlay's + * `right` offset (see `FolderLayoutShell`), so only the cluster is measured here. */ -export function rightChromeReserve(winLinuxCaption: boolean): number { - return RIGHT_CHROME_CLUSTER + (winLinuxCaption ? WINDOW_CAPTION_WIDTH : 0) +export function rightChromeClusterWidth(zoom = 100): number { + return scaleCluster(RIGHT_CHROME_CLUSTER, zoom) } From 40b30a0e8fa94eb973755994a618b1c564061ae8 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 20 Jul 2026 17:09:28 +0800 Subject: [PATCH 3/9] feat(grok): add authentication-method selection to the settings panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Grok settings panel now offers a choice of how to authenticate, instead of always exposing a single XAI_API_KEY field. - Authentication is a choice of official subscription (`grok login`), an XAI_API_KEY, or a custom endpoint; the panel shows only the credential the chosen method needs. - The active method is recognized from the saved configuration — an explicit marker wins, otherwise a configured custom model, then a stored key, then subscription. - The custom-model (bring-your-own-endpoint) fields appear only in custom mode; switching to another method removes the managed custom model from ~/.grok/config.toml on the next save. - Subscription mode relies on the `grok login` credential: any inherited XAI_API_KEY is cleared for the launched session, and a copyable sign-in command is shown. - The ten locales are updated. --- src-tauri/src/acp/connection.rs | 54 ++ .../settings/acp-agent-settings.test.tsx | 75 +++ .../settings/acp-agent-settings.tsx | 575 +++++++++++------- src/i18n/messages/ar.json | 10 +- src/i18n/messages/de.json | 10 +- src/i18n/messages/en.json | 10 +- src/i18n/messages/es.json | 10 +- src/i18n/messages/fr.json | 10 +- src/i18n/messages/ja.json | 10 +- src/i18n/messages/ko.json | 10 +- src/i18n/messages/pt.json | 10 +- src/i18n/messages/zh-CN.json | 10 +- src/i18n/messages/zh-TW.json | 10 +- 13 files changed, 589 insertions(+), 215 deletions(-) diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 3763e0d99..5a52fda29 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -105,6 +105,29 @@ fn apply_cursor_env_policy(merged: &mut Vec<(String, String)>, runtime_env: &BTr } } +/// Grok's launch-time credential policy, mirroring [`apply_cursor_env_policy`]. +/// When the user picked the `grok login` subscription (recorded as +/// `GROK_AUTH_MODE=subscription` by the Grok settings panel), scrub any +/// `XAI_API_KEY` inherited from this process's environment so the CLI falls back +/// to the browser-login credential in `~/.grok/auth.json` rather than a leaked +/// shell/container export. An empty value tells the spawn layer (vendored +/// sacp-tokio) to `env_remove` the inherited var. In api_key mode the key is +/// present and non-empty, so nothing is cleared; legacy/no-mode rows are left +/// untouched. +fn apply_grok_env_policy(merged: &mut Vec<(String, String)>, runtime_env: &BTreeMap) { + if runtime_env.get("GROK_AUTH_MODE").map(String::as_str) != Some("subscription") { + return; + } + let key = "XAI_API_KEY"; + let already_set = merged + .iter() + .any(|(k, v)| k == key && !v.trim().is_empty()); + if !already_set { + merged.retain(|(k, _)| k != key); + merged.push((key.to_string(), String::new())); + } +} + /// Prepend `dir` to the PATH entry of `env`, seeding from `fallback_path` when /// `env` has no PATH key of its own. Removes any pre-existing PATH key first /// (case-insensitively when `windows`, since Windows env keys are @@ -553,6 +576,8 @@ async fn build_agent( let mut merged_env = merge_agent_env(env, runtime_env); if agent_type == AgentType::Cursor { apply_cursor_env_policy(&mut merged_env, runtime_env); + } else if agent_type == AgentType::Grok { + apply_grok_env_policy(&mut merged_env, runtime_env); } let env_key_list: Vec<&str> = merged_env.iter().map(|(k, _)| k.as_str()).collect(); if !merged_env.is_empty() { @@ -6589,6 +6614,35 @@ mod tests { } } + #[test] + fn grok_env_policy_clears_inherited_key_only_in_subscription() { + let sub: BTreeMap = + [("GROK_AUTH_MODE".to_string(), "subscription".to_string())].into(); + + // Subscription with no configured key → inject empty (⇒ spawn strips the + // inherited XAI_API_KEY so `grok login` is used). + let mut merged = vec![("PATH".to_string(), "/usr/bin".to_string())]; + apply_grok_env_policy(&mut merged, &sub); + assert!(merged.iter().any(|(k, v)| k == "XAI_API_KEY" && v.is_empty())); + + // A configured key is preserved even in subscription mode (explicit wins). + let mut with_key = vec![("XAI_API_KEY".to_string(), "xai-abc".to_string())]; + apply_grok_env_policy(&mut with_key, &sub); + assert!(with_key + .iter() + .any(|(k, v)| k == "XAI_API_KEY" && v == "xai-abc")); + + // api_key mode and legacy/no-mode rows are left untouched. + for mode in [Some("api_key"), None] { + let rt: BTreeMap = mode + .map(|m| [("GROK_AUTH_MODE".to_string(), m.to_string())].into()) + .unwrap_or_default(); + let mut env = vec![("PATH".to_string(), "/usr/bin".to_string())]; + apply_grok_env_policy(&mut env, &rt); + assert!(!env.iter().any(|(k, _)| k == "XAI_API_KEY")); + } + } + #[test] fn synthesize_edit_single_diff_makes_canonical_edit() { let content = vec![diff_content("/a.rs", Some("old line\n"), "new line\n")]; diff --git a/src/components/settings/acp-agent-settings.test.tsx b/src/components/settings/acp-agent-settings.test.tsx index 16b77e0b1..8533d42c9 100644 --- a/src/components/settings/acp-agent-settings.test.tsx +++ b/src/components/settings/acp-agent-settings.test.tsx @@ -7,6 +7,7 @@ import { buildVersionCheck, configTextForClaudeSave, getAgentChecks, + inferGrokMode, patchImportantConfigText, } from "./acp-agent-settings" import type { AcpAgentInfo, AgentType, PreflightResult } from "@/lib/types" @@ -52,10 +53,13 @@ function fixDisabled(fix: unknown): boolean { // A full Grok draft, custom-model + compaction fields defaulted empty. Typed // from the function's own parameter so it stays in sync with the panel shape. +// Defaults to the `custom` auth method so the custom-model save cases below +// exercise the [model.] path; non-custom gating is covered separately. type GrokDraftInput = Parameters[0] const grokDraft = ( overrides: Partial = {} ): GrokDraftInput => ({ + grokAuthMode: "custom", grokPermissionMode: "", grokReasoningEffort: "", grokCustomModelId: "", @@ -173,6 +177,35 @@ describe("buildGrokStructuredConfig — Grok panel save payload", () => { autoCompactThresholdPercent: 100, }) }) + + // The custom-model group is written ONLY in the `custom` auth method. In + // subscription / api_key mode the [model.] block is dropped even if the + // (now-hidden) custom fields still hold values — so switching away removes it, + // while method-independent fields (reasoning effort) still pass through. + it("drops the custom model when the auth method isn't custom", () => { + const filled = { + grokCustomModelId: "my-grok", + grokCustomBaseUrl: "https://gw/v1", + grokCustomApiKey: "xai-123", + grokCustomApiBackend: "chat_completions", + grokCustomContextWindow: "131072", + } + for (const mode of ["subscription", "api_key"] as const) { + expect( + buildGrokStructuredConfig( + grokDraft({ + ...filled, + grokAuthMode: mode, + grokReasoningEffort: "high", + }) + ) + ).toEqual({ + permissionMode: null, + defaultReasoningEffort: "high", + ...emptyCustoms, + }) + } + }) }) describe("buildGrokSaveOptions — one save persists both surfaces", () => { @@ -221,6 +254,48 @@ describe("buildGrokSaveOptions — one save persists both surfaces", () => { }) }) +describe("inferGrokMode — Grok auth-method recognition", () => { + // An explicit knob always wins, even against a present/absent key. + it("honors an explicit GROK_AUTH_MODE knob", () => { + expect(inferGrokMode({ GROK_AUTH_MODE: "subscription" })).toBe( + "subscription" + ) + expect(inferGrokMode({ GROK_AUTH_MODE: "api_key", XAI_API_KEY: "" })).toBe( + "api_key" + ) + // Subscription wins even if a stale key lingers in env. + expect( + inferGrokMode({ GROK_AUTH_MODE: "subscription", XAI_API_KEY: "xai-1" }) + ).toBe("subscription") + }) + + // Legacy rows (no knob): a saved key implies api_key, else subscription. + it("falls back to XAI_API_KEY presence for legacy rows", () => { + expect(inferGrokMode({ XAI_API_KEY: "xai-abc" })).toBe("api_key") + expect(inferGrokMode({ XAI_API_KEY: " " })).toBe("subscription") + expect(inferGrokMode({})).toBe("subscription") + }) + + // An unrecognized knob value is ignored, falling through to key presence. + it("ignores an unknown knob value", () => { + expect(inferGrokMode({ GROK_AUTH_MODE: "bogus" })).toBe("subscription") + expect( + inferGrokMode({ GROK_AUTH_MODE: "bogus", XAI_API_KEY: "xai-1" }) + ).toBe("api_key") + }) + + // The custom-endpoint method: an explicit knob wins; else a configured custom + // model (in config.toml, surfaced via the hasCustomModel flag) implies it and + // outranks a lingering key. + it("recognizes the custom endpoint method", () => { + expect(inferGrokMode({ GROK_AUTH_MODE: "custom" })).toBe("custom") + expect(inferGrokMode({}, true)).toBe("custom") + expect(inferGrokMode({ XAI_API_KEY: "xai-1" }, true)).toBe("custom") + // An explicit knob still wins over the custom-model flag. + expect(inferGrokMode({ GROK_AUTH_MODE: "api_key" }, true)).toBe("api_key") + }) +}) + describe("buildVersionCheck", () => { // uv runtime not ready: a uvx agent (Hermes) must surface a blocked // version-status with the agent-install action DISABLED — the actual install diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index 4289c2217..08d5246b1 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -188,6 +188,10 @@ interface AgentDraft { * Drives the model editor + `model_catalog_json` generation on save. */ codexModelList: CodexModelConfig grokConfigTomlText: string + // Grok authentication method (subscription via `grok login` vs XAI_API_KEY). + // Recorded in env as GROK_AUTH_MODE; drives which credential body renders and + // whether XAI_API_KEY is stripped from env on subscription. + grokAuthMode: GrokAuthMethod // Grok structured controls (empty string = "unset / use default"). Backed by // ~/.grok/config.toml [ui].permission_mode / [models].default_reasoning_effort; // merged onto the current on-disk config server-side on save. @@ -429,6 +433,51 @@ const GROK_UNSET = "__grok_unset__" * `chat_completions`, an Anthropic-format endpoint `messages`. */ const GROK_DEFAULT_API_BACKEND = "responses" +/** Grok's real credential env var (mirrors the backend `agent_env_keys(Grok)` + * and `importantEnvKeysByAgent`). */ +const GROK_API_KEY_ENV = "XAI_API_KEY" + +/** codeg-side knob recording the chosen authentication method. Read by the + * launch path (`apply_grok_env_policy`): in `subscription` mode it clears any + * inherited XAI_API_KEY so the CLI uses the `grok login` browser credential. + * The Grok binary itself ignores this var. Mirrors Cursor's CURSOR_AUTH_MODE. */ +const GROK_AUTH_MODE_ENV = "GROK_AUTH_MODE" + +/** The subscription sign-in command shown (and copied) in the auth card. Grok's + * `login` is a root subcommand; a bare `grok login` matches the panel's existing + * hint wording (codeg doesn't resolve the managed binary path here). */ +const GROK_LOGIN_COMMAND = "grok login" + +/** Grok's three authentication methods: + * - `subscription` — sign in with `grok login` (SuperGrok / X Premium+), + * whose session lives in `~/.grok/auth.json` (untouched by codeg); + * - `api_key` — a non-interactive XAI_API_KEY from the xAI console; + * - `custom` — a bring-your-own endpoint: a custom `[model.]` in + * ~/.grok/config.toml with its own base_url/api_key (the custom-model card). */ +export type GrokAuthMethod = "subscription" | "api_key" | "custom" + +/** Resolve the persisted Grok authentication method, tolerant of legacy rows: + * an explicit `GROK_AUTH_MODE` wins; otherwise a configured custom model implies + * `custom`, a saved XAI_API_KEY implies `api_key`, and an empty env means the + * user relies on `grok login`. `hasCustomModel` reflects whether a codeg-managed + * `[model.]` is set (it lives in config.toml, not env). Mirrors + * `inferCursorMode`. */ +export function inferGrokMode( + env: Record, + hasCustomModel = false +): GrokAuthMethod { + const explicit = (env[GROK_AUTH_MODE_ENV] ?? "").trim() + if ( + explicit === "subscription" || + explicit === "api_key" || + explicit === "custom" + ) { + return explicit + } + if (hasCustomModel) return "custom" + return (env[GROK_API_KEY_ENV] ?? "").trim() ? "api_key" : "subscription" +} + function importantEnvKeysByAgent(agentType: AgentType): ImportantEnvKeys { if (agentType === "claude_code") { return { @@ -2345,6 +2394,7 @@ function patchCodexConfigTomlText( * for the two managed keys. */ export function buildGrokStructuredConfig(draft: { + grokAuthMode: GrokAuthMethod grokPermissionMode: string grokReasoningEffort: string grokCustomModelId: string @@ -2354,7 +2404,12 @@ export function buildGrokStructuredConfig(draft: { grokCustomContextWindow: string grokAutoCompactThreshold: string }): GrokStructuredConfig { - const modelId = draft.grokCustomModelId.trim() + // The custom-model group applies only in the `custom` auth method; the + // subscription / api_key methods omit the codeg-managed [model.] block + // (an empty id → the backend removes it). Permission mode, reasoning effort + // and compaction below stay independent of the auth method. + const modelId = + draft.grokAuthMode === "custom" ? draft.grokCustomModelId.trim() : "" const positiveInt = (raw: string): number | null => { const n = Number.parseInt(raw.trim(), 10) return Number.isFinite(n) && n > 0 ? n : null @@ -2395,6 +2450,7 @@ export function buildGrokStructuredConfig(draft: { */ export function buildGrokSaveOptions( draft: { + grokAuthMode: GrokAuthMethod grokPermissionMode: string grokReasoningEffort: string grokCustomModelId: string @@ -2704,15 +2760,30 @@ function buildAgentDraft(agent: AcpAgentInfo): AgentDraft { : agent.agent_type === "codex" ? inferCodexAuthMode(codexAuthJsonText) : "api_key" + const grokAuthMode: GrokAuthMethod = + agent.agent_type === "grok" + ? inferGrokMode( + agent.env, + Boolean(agent.grok_settings?.custom_model_id?.trim()) + ) + : "api_key" const rawEnvText = envMapToText(agent.env) - // When codex is in official subscription mode, clean up API keys/URLs from env + // When codex is in official subscription mode, clean up API keys/URLs from env. + // Grok mirrors this: record the auth-method knob, and in subscription mode + // strip XAI_API_KEY so the editable env can't override the `grok login` + // credential (the launch path enforces the same — see apply_grok_env_policy). const envText = agent.agent_type === "codex" && codexAuthMode === "chatgpt_subscription" ? patchEnvText(rawEnvText, { OPENAI_API_KEY: "", OPENAI_BASE_URL: "", }) - : rawEnvText + : agent.agent_type === "grok" + ? patchEnvText(rawEnvText, { + GROK_AUTH_MODE: grokAuthMode, + ...(grokAuthMode === "subscription" ? { XAI_API_KEY: "" } : {}), + }) + : rawEnvText return { enabled: agent.enabled, envText, @@ -2781,6 +2852,7 @@ function buildAgentDraft(agent: AcpAgentInfo): AgentDraft { codexConfigTomlText, codexModelList: parseCodexModelConfig(agent.codex_model_catalog ?? null), grokConfigTomlText, + grokAuthMode, grokPermissionMode, grokReasoningEffort, grokCustomModelId: agent.grok_settings?.custom_model_id ?? "", @@ -5261,6 +5333,31 @@ export function AcpAgentSettings() { [selectedAgent, selectedDraft, t, updateSelectedDraft] ) + const handleGrokAuthModeChange = useCallback( + (nextMode: GrokAuthMethod) => { + if ( + !selectedAgent || + !selectedDraft || + selectedAgent.agent_type !== "grok" + ) + return + // Record the method knob in env; on subscription strip XAI_API_KEY so the + // editable env can't override the `grok login` credential (the launch path + // enforces the same via apply_grok_env_policy). Clearing the draft apiKey + // keeps the now-hidden key input from resurrecting a stale value. + updateSelectedDraft((current) => ({ + ...current, + grokAuthMode: nextMode, + apiKey: nextMode === "subscription" ? "" : current.apiKey, + envText: patchEnvText(current.envText, { + GROK_AUTH_MODE: nextMode, + ...(nextMode === "subscription" ? { XAI_API_KEY: "" } : {}), + }), + })) + }, + [selectedAgent, selectedDraft, updateSelectedDraft] + ) + const handleClaudeAuthModeChange = useCallback( (nextMode: ClaudeAuthMode) => { if ( @@ -9722,248 +9819,316 @@ supports_websockets = true`}
- {/* Authentication — status + inline XAI_API_KEY */} -
-
+ {/* Authentication — method selector + method-specific body. + Mirrors the Cursor panel: an explicit choice between the + `grok login` subscription and an XAI_API_KEY, recognized + on load via inferGrokMode and recorded as GROK_AUTH_MODE. */} +
+
-

- {selectedDraft.apiKey.trim() - ? t("grok.authKeyConfigured") - : t("grok.authKeyMissing")} + +

+ {selectedDraft.grokAuthMode === "subscription" + ? t("grok.subscriptionHint") + : selectedDraft.grokAuthMode === "custom" + ? t("grok.authModeCustomHint") + : t("grok.authModeApiKeyHint")}

-
- -
- - handleImportantConfigChange( - "apiKey", - event.target.value - ) - } - placeholder="xai-..." - aria-label="XAI_API_KEY" - name="grok-xai-api-key" - autoComplete="off" - spellCheck={false} - disabled={grokSaving} - /> - + + {selectedDraft.grokAuthMode === "subscription" ? ( + // Subscription: a copyable `grok login` command. Its + // session lives in ~/.grok/auth.json (untouched here); the + // launch path strips any inherited XAI_API_KEY. +
+

+ {t("grok.loginHint")} +

+
+ + {GROK_LOGIN_COMMAND} + + +
-
-

- {t("grok.authLoginHint")} -

+ ) : selectedDraft.grokAuthMode === "api_key" ? ( + // API key: the non-interactive XAI_API_KEY credential. +
+ +
+ + handleImportantConfigChange( + "apiKey", + event.target.value + ) + } + placeholder="xai-..." + aria-label="XAI_API_KEY" + name="grok-xai-api-key" + autoComplete="off" + spellCheck={false} + disabled={grokSaving} + /> + +
+

+ {selectedDraft.apiKey.trim() + ? t("grok.authKeyConfigured") + : t("grok.authKeyMissing")} +

+
+ ) : null}
{/* Custom model (BYO endpoint) → [model.] + [models].default. - A model id here registers a custom Grok model and makes it - the default; empty id removes the codeg-managed block. */} -
-
- -

- {t("grok.customModelHint")} -

-
- -
- - - updateSelectedDraft((current) => ({ - ...current, - grokCustomModelId: event.target.value, - })) - } - placeholder={t("grok.customModelIdPlaceholder")} - aria-label={t("grok.customModelIdLabel")} - autoComplete="off" - spellCheck={false} - disabled={grokSaving} - /> -

- {t("grok.customModelIdHint")} -

-
+ Only shown (and saved) in the `custom` auth method: a model + id registers a custom Grok model as the default; the other + methods omit the codeg-managed block. */} + {selectedDraft.grokAuthMode === "custom" ? ( +
+
+ +

+ {t("grok.customModelHint")} +

+
-
updateSelectedDraft((current) => ({ ...current, - grokCustomBaseUrl: event.target.value, + grokCustomModelId: event.target.value, })) } - placeholder={t("grok.customBaseUrlPlaceholder")} - aria-label={t("grok.customBaseUrlLabel")} + placeholder={t("grok.customModelIdPlaceholder")} + aria-label={t("grok.customModelIdLabel")} autoComplete="off" spellCheck={false} disabled={grokSaving} /> +

+ {t("grok.customModelIdHint")} +

+ +
+
+ + + updateSelectedDraft((current) => ({ + ...current, + grokCustomBaseUrl: event.target.value, + })) + } + placeholder={t("grok.customBaseUrlPlaceholder")} + aria-label={t("grok.customBaseUrlLabel")} + autoComplete="off" + spellCheck={false} + disabled={grokSaving} + /> +
+
+ + +
+
+
- + updateSelectedDraft((current) => ({ + ...current, + grokCustomApiKey: event.target.value, + })) + } + placeholder="xai-..." + aria-label={t("grok.customApiKeyLabel")} + name="grok-custom-api-key" + autoComplete="off" + spellCheck={false} + disabled={grokSaving} + /> + +
+

+ {t("grok.customApiKeyHint")} +

-
-
- -
+
+ updateSelectedDraft((current) => ({ ...current, - grokCustomApiKey: event.target.value, + grokCustomContextWindow: event.target.value, })) } - placeholder="xai-..." - aria-label={t("grok.customApiKeyLabel")} - name="grok-custom-api-key" - autoComplete="off" - spellCheck={false} + placeholder="500000" + aria-label={t("grok.customContextWindowLabel")} disabled={grokSaving} /> - +

+ {t("grok.customContextWindowHint")} +

-

- {t("grok.customApiKeyHint")} -

- -
- - - updateSelectedDraft((current) => ({ - ...current, - grokCustomContextWindow: event.target.value, - })) - } - placeholder="500000" - aria-label={t("grok.customContextWindowLabel")} - disabled={grokSaving} - /> -

- {t("grok.customContextWindowHint")} -

-
-
+ ) : null} {/* Compaction — session-global auto-compact threshold. */}
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 93e4d7385..97cd7b4a4 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -638,9 +638,17 @@ "effortXhigh": "الأقصى", "optionDefault": "استخدام الافتراضي", "authTitle": "المصادقة", + "authMode": "طريقة المصادقة", + "authModeApiKey": "مفتاح XAI API", + "authModeApiKeyHint": "المصادقة باستخدام XAI_API_KEY من وحدة تحكم xAI — للتشغيل غير التفاعلي أو بدون واجهة. يُخزَّن في بيئة هذا الوكيل.", + "authModeCustom": "نقطة نهاية مخصّصة", + "authModeCustomHint": "استخدم نقطة نهاية خاصة بك (BYO): عرّف نموذجًا مخصّصًا في الأسفل مع عنوان URL أساسي ومفتاح API خاصين به. يصبح النموذج الافتراضي لـ Grok.", + "subscriptionHint": "سجّل الدخول باستخدام `grok login` (SuperGrok / X Premium+). لا يُخزَّن أي مفتاح API.", + "loginHint": "نفّذ هذا في الطرفية لتسجيل الدخول، ثم أعد فتح هذه الإعدادات:", + "commandCopied": "تم نسخ الأمر إلى الحافظة", + "copyCommand": "نسخ الأمر", "authKeyConfigured": "XAI_API_KEY مُهيَّأ.", "authKeyMissing": "لم يتم تعيين XAI_API_KEY.", - "authLoginHint": "عيّن XAI_API_KEY هنا، أو نفّذ `grok login` لتسجيل الدخول بالاشتراك (SuperGrok / X Premium+).", "advancedToggle": "متقدم (config.toml الخام)", "configTomlNative": "config.toml (أصلي)", "configTomlHint": "يُحفظ حرفيًا كملف ~/.grok/config.toml بالكامل. يُرفض TOML غير الصالح حتى لا يقتطع خطأ مطبعي ملفك أبدًا.", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index edff4f922..e85fce356 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -638,9 +638,17 @@ "effortXhigh": "Maximal", "optionDefault": "Standard verwenden", "authTitle": "Authentifizierung", + "authMode": "Authentifizierungsmethode", + "authModeApiKey": "XAI-API-Schlüssel", + "authModeApiKeyHint": "Authentifiziere dich mit einem XAI_API_KEY aus der xAI-Konsole – für nicht-interaktive oder Headless-Läufe. Wird in der Umgebung dieses Agenten gespeichert.", + "authModeCustom": "Benutzerdefinierter Endpunkt", + "authModeCustomHint": "Verwende einen eigenen Endpunkt (BYO): Definiere unten ein benutzerdefiniertes Modell mit eigener Basis-URL und eigenem API-Schlüssel. Es wird zum Standardmodell von Grok.", + "subscriptionHint": "Melde dich mit `grok login` an (SuperGrok / X Premium+). Es wird kein API-Schlüssel gespeichert.", + "loginHint": "Führe dies in einem Terminal aus, um dich anzumelden, und öffne dann diese Einstellungen erneut:", + "commandCopied": "Befehl in die Zwischenablage kopiert", + "copyCommand": "Befehl kopieren", "authKeyConfigured": "XAI_API_KEY ist konfiguriert.", "authKeyMissing": "Kein XAI_API_KEY festgelegt.", - "authLoginHint": "Lege hier XAI_API_KEY fest oder führe `grok login` für die Abo-Anmeldung aus (SuperGrok / X Premium+).", "advancedToggle": "Erweitert (rohe config.toml)", "configTomlNative": "config.toml (nativ)", "configTomlHint": "Wird wortwörtlich als gesamte ~/.grok/config.toml gespeichert. Ungültiges TOML wird abgelehnt, sodass ein Tippfehler deine Datei nie abschneidet.", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 182503e5c..7b7439585 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -638,9 +638,17 @@ "effortXhigh": "Max", "optionDefault": "Use default", "authTitle": "Authentication", + "authMode": "Authentication method", + "authModeApiKey": "XAI API key", + "authModeApiKeyHint": "Authenticate with an XAI_API_KEY from the xAI console — for non-interactive or headless runs. Stored in this agent's environment.", + "authModeCustom": "Custom endpoint", + "authModeCustomHint": "Use a bring-your-own endpoint: define a custom model below with its own base URL and API key. It becomes Grok's default model.", + "subscriptionHint": "Sign in with `grok login` (SuperGrok / X Premium+). No API key is stored.", + "loginHint": "Run this in a terminal to sign in, then reopen these settings:", + "commandCopied": "Command copied to clipboard", + "copyCommand": "Copy command", "authKeyConfigured": "XAI_API_KEY is configured.", "authKeyMissing": "No XAI_API_KEY set.", - "authLoginHint": "Set XAI_API_KEY here, or run `grok login` for subscription sign-in (SuperGrok / X Premium+).", "advancedToggle": "Advanced (raw config.toml)", "configTomlNative": "config.toml (native)", "configTomlHint": "Saved verbatim as the whole ~/.grok/config.toml. Invalid TOML is rejected so a typo never truncates your file.", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 79ae3d89d..4247c91b3 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -638,9 +638,17 @@ "effortXhigh": "Máximo", "optionDefault": "Usar predeterminado", "authTitle": "Autenticación", + "authMode": "Método de autenticación", + "authModeApiKey": "Clave de API de XAI", + "authModeApiKeyHint": "Autentícate con una XAI_API_KEY de la consola de xAI, para ejecuciones no interactivas o headless. Se guarda en el entorno de este agente.", + "authModeCustom": "Endpoint personalizado", + "authModeCustomHint": "Usa un endpoint propio (BYO): define abajo un modelo personalizado con su propia base URL y clave de API. Se convierte en el modelo predeterminado de Grok.", + "subscriptionHint": "Inicia sesión con `grok login` (SuperGrok / X Premium+). No se guarda ninguna clave de API.", + "loginHint": "Ejecuta esto en una terminal para iniciar sesión y luego vuelve a abrir esta configuración:", + "commandCopied": "Comando copiado al portapapeles", + "copyCommand": "Copiar comando", "authKeyConfigured": "XAI_API_KEY está configurada.", "authKeyMissing": "No hay XAI_API_KEY configurada.", - "authLoginHint": "Configura XAI_API_KEY aquí o ejecuta `grok login` para iniciar sesión con suscripción (SuperGrok / X Premium+).", "advancedToggle": "Avanzado (config.toml sin procesar)", "configTomlNative": "config.toml (nativo)", "configTomlHint": "Se guarda literalmente como todo el ~/.grok/config.toml. El TOML no válido se rechaza para que una errata no trunque tu archivo.", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 345441311..b885c70af 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -638,9 +638,17 @@ "effortXhigh": "Maximum", "optionDefault": "Utiliser la valeur par défaut", "authTitle": "Authentification", + "authMode": "Méthode d'authentification", + "authModeApiKey": "Clé API XAI", + "authModeApiKeyHint": "Authentifiez-vous avec une XAI_API_KEY de la console xAI, pour les exécutions non interactives ou headless. Enregistrée dans l'environnement de cet agent.", + "authModeCustom": "Point de terminaison personnalisé", + "authModeCustomHint": "Utilisez votre propre point de terminaison (BYO) : définissez ci-dessous un modèle personnalisé avec sa propre URL de base et sa clé API. Il devient le modèle par défaut de Grok.", + "subscriptionHint": "Connectez-vous avec `grok login` (SuperGrok / X Premium+). Aucune clé API n'est enregistrée.", + "loginHint": "Exécutez ceci dans un terminal pour vous connecter, puis rouvrez ces paramètres :", + "commandCopied": "Commande copiée dans le presse-papiers", + "copyCommand": "Copier la commande", "authKeyConfigured": "XAI_API_KEY est configurée.", "authKeyMissing": "Aucune XAI_API_KEY définie.", - "authLoginHint": "Définissez XAI_API_KEY ici, ou exécutez `grok login` pour la connexion par abonnement (SuperGrok / X Premium+).", "advancedToggle": "Avancé (config.toml brut)", "configTomlNative": "config.toml (natif)", "configTomlHint": "Enregistré tel quel comme l'intégralité de ~/.grok/config.toml. Le TOML invalide est rejeté afin qu'une faute de frappe ne tronque jamais votre fichier.", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 5b2dfcb27..319de5617 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -638,9 +638,17 @@ "effortXhigh": "最大", "optionDefault": "デフォルトを使用", "authTitle": "認証", + "authMode": "認証方式", + "authModeApiKey": "XAI API キー", + "authModeApiKeyHint": "xAI コンソールの XAI_API_KEY で認証します。非対話・ヘッドレス実行向けで、このエージェントの環境変数に保存されます。", + "authModeCustom": "カスタムエンドポイント", + "authModeCustomHint": "独自のエンドポイント(BYO)を使用します。下でベース URL と API キーを持つカスタムモデルを定義すると、それが Grok の既定モデルになります。", + "subscriptionHint": "`grok login` でサインインします(SuperGrok / X Premium+)。API キーは保存されません。", + "loginHint": "ターミナルで次のコマンドを実行してサインインし、この設定を開き直してください:", + "commandCopied": "コマンドをクリップボードにコピーしました", + "copyCommand": "コマンドをコピー", "authKeyConfigured": "XAI_API_KEY が設定されています。", "authKeyMissing": "XAI_API_KEY が未設定です。", - "authLoginHint": "ここで XAI_API_KEY を設定するか、`grok login` でサブスクリプションサインイン(SuperGrok / X Premium+)を行います。", "advancedToggle": "詳細(生の config.toml)", "configTomlNative": "config.toml(ネイティブ)", "configTomlHint": "~/.grok/config.toml 全体としてそのまま書き込まれます。無効な TOML は拒否され、タイプミスでファイルが切り詰められることはありません。", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index af4e955a1..1060c7000 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -638,9 +638,17 @@ "effortXhigh": "최대", "optionDefault": "기본값 사용", "authTitle": "인증", + "authMode": "인증 방식", + "authModeApiKey": "XAI API 키", + "authModeApiKeyHint": "xAI 콘솔의 XAI_API_KEY로 인증합니다. 비대화형·헤드리스 실행에 적합하며 이 에이전트의 환경 변수에 저장됩니다.", + "authModeCustom": "사용자 지정 엔드포인트", + "authModeCustomHint": "자체 엔드포인트(BYO)를 사용합니다. 아래에서 자체 base URL과 API 키를 가진 사용자 지정 모델을 정의하면 Grok의 기본 모델이 됩니다.", + "subscriptionHint": "`grok login`으로 로그인합니다(SuperGrok / X Premium+). API 키는 저장되지 않습니다.", + "loginHint": "터미널에서 다음 명령을 실행해 로그인한 뒤 이 설정을 다시 여세요:", + "commandCopied": "명령을 클립보드에 복사했습니다", + "copyCommand": "명령 복사", "authKeyConfigured": "XAI_API_KEY가 구성되어 있습니다.", "authKeyMissing": "XAI_API_KEY가 설정되지 않았습니다.", - "authLoginHint": "여기에서 XAI_API_KEY를 설정하거나 `grok login`으로 구독 로그인(SuperGrok / X Premium+)을 실행하세요.", "advancedToggle": "고급(원본 config.toml)", "configTomlNative": "config.toml(네이티브)", "configTomlHint": "~/.grok/config.toml 전체로 그대로 저장됩니다. 잘못된 TOML은 거부되어 오타로 파일이 잘리지 않습니다.", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 6e010b7ed..f4cbd6ccb 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -638,9 +638,17 @@ "effortXhigh": "Máximo", "optionDefault": "Usar padrão", "authTitle": "Autenticação", + "authMode": "Método de autenticação", + "authModeApiKey": "Chave de API da XAI", + "authModeApiKeyHint": "Autentique-se com uma XAI_API_KEY do console da xAI, para execuções não interativas ou headless. Armazenada no ambiente deste agente.", + "authModeCustom": "Endpoint personalizado", + "authModeCustomHint": "Use um endpoint próprio (BYO): defina abaixo um modelo personalizado com a sua própria base URL e chave de API. Ele se torna o modelo padrão do Grok.", + "subscriptionHint": "Entre com `grok login` (SuperGrok / X Premium+). Nenhuma chave de API é armazenada.", + "loginHint": "Execute isto num terminal para entrar e depois reabra estas configurações:", + "commandCopied": "Comando copiado para a área de transferência", + "copyCommand": "Copiar comando", "authKeyConfigured": "XAI_API_KEY está configurada.", "authKeyMissing": "Nenhuma XAI_API_KEY definida.", - "authLoginHint": "Defina XAI_API_KEY aqui ou execute `grok login` para login por assinatura (SuperGrok / X Premium+).", "advancedToggle": "Avançado (config.toml bruto)", "configTomlNative": "config.toml (nativo)", "configTomlHint": "Salvo literalmente como todo o ~/.grok/config.toml. TOML inválido é rejeitado para que um erro de digitação nunca trunque seu arquivo.", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 6823dd61c..04d830dfb 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -638,9 +638,17 @@ "effortXhigh": "最高", "optionDefault": "使用默认", "authTitle": "认证", + "authMode": "认证方式", + "authModeApiKey": "XAI API 密钥", + "authModeApiKeyHint": "使用 xAI 控制台的 XAI_API_KEY 认证——适用于非交互/无头运行。保存在该智能体的环境变量中。", + "authModeCustom": "自定义接口", + "authModeCustomHint": "使用自定义接口(BYO 端点):在下方定义一个带有独立 base URL 和 API 密钥的自定义模型,它将成为 Grok 的默认模型。", + "subscriptionHint": "使用 `grok login` 登录(SuperGrok / X Premium+)。不保存 API 密钥。", + "loginHint": "在终端运行以下命令登录,然后重新打开此设置:", + "commandCopied": "命令已复制到剪贴板", + "copyCommand": "复制命令", "authKeyConfigured": "已配置 XAI_API_KEY。", "authKeyMissing": "未设置 XAI_API_KEY。", - "authLoginHint": "在此设置 XAI_API_KEY,或运行 `grok login` 进行订阅登录(SuperGrok / X Premium+)。", "advancedToggle": "高级(原始 config.toml)", "configTomlNative": "config.toml(原生)", "configTomlHint": "按原样整体写入 ~/.grok/config.toml。非法 TOML 会被拒绝,绝不因笔误截断文件。", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index d50a070dc..311a37802 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -638,9 +638,17 @@ "effortXhigh": "最高", "optionDefault": "使用預設", "authTitle": "驗證", + "authMode": "認證方式", + "authModeApiKey": "XAI API 金鑰", + "authModeApiKeyHint": "使用 xAI 主控台的 XAI_API_KEY 認證——適用於非互動/無頭執行。儲存在該智慧體的環境變數中。", + "authModeCustom": "自訂接口", + "authModeCustomHint": "使用自訂接口(BYO 端點):在下方定義一個帶有獨立 base URL 和 API 金鑰的自訂模型,它將成為 Grok 的預設模型。", + "subscriptionHint": "使用 `grok login` 登入(SuperGrok / X Premium+)。不會儲存 API 金鑰。", + "loginHint": "在終端機執行以下指令登入,然後重新開啟此設定:", + "commandCopied": "指令已複製到剪貼簿", + "copyCommand": "複製指令", "authKeyConfigured": "已設定 XAI_API_KEY。", "authKeyMissing": "未設定 XAI_API_KEY。", - "authLoginHint": "在此設定 XAI_API_KEY,或執行 `grok login` 進行訂閱登入(SuperGrok / X Premium+)。", "advancedToggle": "進階(原始 config.toml)", "configTomlNative": "config.toml(原生)", "configTomlHint": "按原樣整體寫入 ~/.grok/config.toml。非法 TOML 會被拒絕,絕不因筆誤截斷檔案。", From ce624deaaf635749ef8802dd71dcd9bee9c7fa3e Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 20 Jul 2026 19:06:36 +0800 Subject: [PATCH 4/9] feat(file-tree): add keyboard navigation to the workspace file tree The right-sidebar file tree can now be operated entirely from the keyboard, IntelliJ-style. - Up/Down move the selection between visible rows; Right expands a collapsed directory or steps into its first child; Left collapses an expanded directory or moves to the parent. - Enter or Space opens the selected file or toggles the selected directory; Home and End jump to the first and last visible row. - The selected row is highlighted and kept scrolled into view, and the tree is a single tab stop that exposes the active row through aria-activedescendant instead of making every row individually tabbable. --- src/components/ai-elements/file-tree.test.tsx | 61 +++++ src/components/ai-elements/file-tree.tsx | 89 +++++- .../layout/aux-panel-file-tree-tab.tsx | 107 +++++++- src/lib/file-tree-keyboard.test.ts | 253 ++++++++++++++++++ src/lib/file-tree-keyboard.ts | 158 +++++++++++ 5 files changed, 659 insertions(+), 9 deletions(-) create mode 100644 src/components/ai-elements/file-tree.test.tsx create mode 100644 src/lib/file-tree-keyboard.test.ts create mode 100644 src/lib/file-tree-keyboard.ts diff --git a/src/components/ai-elements/file-tree.test.tsx b/src/components/ai-elements/file-tree.test.tsx new file mode 100644 index 000000000..aa6d0da8e --- /dev/null +++ b/src/components/ai-elements/file-tree.test.tsx @@ -0,0 +1,61 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { FileTree, FileTreeFile, FileTreeFolder } from "./file-tree" + +function renderTree(keyboardNavigation: boolean) { + return render( + + + + + + ) +} + +describe("FileTree keyboard focus topology", () => { + it("collapses the opted-in tree to a single tab stop: only the container", () => { + renderTree(true) + + const container = screen.getByRole("tree") + const [folderItem, fileItem] = screen.getAllByRole("treeitem") + const folderButton = screen.getByRole("button", { name: "dir" }) + + // The container is the sole focus host... + expect(container.tabIndex).toBe(0) + // ...and every row — including the folder's native
@@ -186,8 +238,14 @@ export const FileTreeFolder = ({ style: rowStyle, ...rowRest } = rowProps ?? {} - const { expandedPaths, togglePath, selectedPath, onSelect } = - useContext(FileTreeContext) + const { + expandedPaths, + togglePath, + selectedPath, + onSelect, + treeId, + keyboardNavigation, + } = useContext(FileTreeContext) const isExpanded = expandedPaths.has(path) const isSelected = selectedPath === path @@ -209,9 +267,11 @@ export const FileTreeFolder = ({
@@ -230,8 +290,18 @@ export const FileTreeFolder = ({ style={rowPaddingLeftStyle(depth, rowStyle)} onClick={handleSelect} type="button" + // Scroll target for keyboard navigation: the header button (not the + // outer treeitem, whose box spans the whole expanded subtree and + // would scroll past the header). + data-tree-row-path={path} data-tree-drop-dir={dropTargetDir} {...rowRest} + // The header is a native + {isSelected && } + ) } - const localSectionKey = sectionKey("local") - const remoteSectionKey = sectionKey("remote") - return ( -
+ // Borderless, edge-aligned header mirroring the session-details tab: the + // branch trigger sits flush-left (px-0 lands its icon on the header's px-3 + // inset) and the refresh button flush-right, spread by justify-between. +
-
- {branchList.local.length > 0 && ( - toggle(localSectionKey)} - > - - - {t("localBranches")} - - - - - - )} - {branchList.remote.length > 0 && ( - toggle(remoteSectionKey)} - > - - - {t("remoteBranches")} - - - {remoteSections.map((section) => - section.remoteName == null ? ( - - ) : ( - toggle(section.key)} - > - - - {section.remoteName} ({section.count}) - - - - - - ) - )} - - - )} -
+ + + + {t("noBranches")} + {branchList.local.length > 0 && ( + + {isSearching + ? branchList.local.map((b) => renderFlatItem(b, "local")) + : renderTreeItems(localNodes, 0)} + + )} + {branchList.remote.length > 0 && ( + + {isSearching + ? branchList.remote.map((b) => renderFlatItem(b, "remote")) + : remoteSections.map((section) => + section.remoteName == null ? ( + + {renderTreeItems(section.nodes, 0)} + + ) : ( + + toggle(section.key)} + style={indentStyle(0)} + > + + + {section.remoteName} + + + {section.count} + + + {isExpanded(section.key) && + renderTreeItems(section.nodes, 1)} + + ) + )} + + )} + +
+ {/* justify-end flushes the glyph to the trailing edge (13px inset, + mirroring the branch trigger's flush-left glyph), and a text-only hover + drops ghost's padded bg box — hover:bg-transparent + its dark twin — so + the two header edges read symmetrically instead of the right growing a + wider box on hover. size-8 stays for the click target. */}
) @@ -754,6 +807,7 @@ function BranchSelector({ export function GitLogTab() { const t = useTranslations("Folder.gitLogTab") const tCommon = useTranslations("Folder.common") + const isMobile = useIsMobile() // Defer the folder so a cross-folder conversation-tab switch commits first and // this tab's git-log refetch + commit-row render runs in a non-blocking // transition a frame later instead of janking the switch (see the file-tree / @@ -776,7 +830,6 @@ export function GitLogTab() { const [refreshing, setRefreshing] = useState(false) const [error, setError] = useState(null) const [notAGitRepo, setNotAGitRepo] = useState(false) - const [scrolled, setScrolled] = useState(false) const [openByCommit, setOpenByCommit] = useState>({}) const [branchesByCommit, setBranchesByCommit] = useState< Record @@ -802,6 +855,40 @@ export function GitLogTab() { const [resetMode, setResetMode] = useState("mixed") const [resetting, setResetting] = useState(false) + // ── Pagination (infinite scroll) ────────────────────────────────────────── + // The log loads in PAGE_SIZE pages via the backend `skip` offset and appends + // older commits as the scroll nears the end (see loadMore / handleVirtuaScroll). + const [hasMore, setHasMore] = useState(false) + const [loadingMore, setLoadingMore] = useState(false) + // virtua binds to the real OverlayScrollbars viewport (surfaced via the + // ScrollArea onViewportRef bridge once OS initializes): a ref for the + // Virtualizer scrollRef + a state flag so it only mounts once the viewport + // exists (mirrors the sidebar conversation list). + const viewportRef = useRef(null) + const [viewportEl, setViewportEl] = useState(null) + const handleViewportRef = useCallback((element: HTMLElement | null) => { + viewportRef.current = element + setViewportEl(element) + }, []) + const virtualizerRef = useRef(null) + // Generation guard: bumped on every page-0 (re)load so an in-flight loadMore + // can't append stale commits after a refresh / branch switch. Live snapshots + // (entries length, hasMore, in-flight) are mirrored to refs so the scroll + // callback stays referentially stable and never acts on stale state. + const logGenRef = useRef(0) + const loadingMoreRef = useRef(false) + // True while a page-0 (re)load is in flight: appends must not start (their + // captured length would be reset by the load, punching an offset gap) until it + // settles. + const fetchingRef = useRef(false) + // Kept in sync with state every render AND eagerly on each mutation below, so + // the scroll callback / loadMore read the just-committed length within the same + // tick (before React re-renders) and request the correct next offset. + const entriesRef = useRef(entries) + entriesRef.current = entries + const hasMoreRef = useRef(hasMore) + hasMoreRef.current = hasMore + // Close the create-branch / reset dialogs the instant the ACTIVE folder // changes (keyed on the live id, not the deferred `folder`) so a dialog opened // under the previous folder can't create-branch / reset against the new one @@ -901,9 +988,27 @@ export function GitLogTab() { } setError(null) setNotAGitRepo(false) + // New generation: this page-0 (re)load supersedes any in-flight loadMore + // and blocks new appends (via fetchingRef) until it settles. It does NOT + // touch loadingMoreRef — a superseded append still owns and releases its + // own lock, so freeing it here could free a newer append's lock. + const gen = ++logGenRef.current + fetchingRef.current = true + setLoadingMore(false) try { - const result = await gitLog(folder.path, 100, branch ?? undefined) + const result = await gitLog( + folder.path, + PAGE_SIZE, + branch ?? undefined, + undefined, + 0 + ) + if (gen !== logGenRef.current) return setEntries(result.entries) + entriesRef.current = result.entries + const more = result.entries.length >= PAGE_SIZE + setHasMore(more) + hasMoreRef.current = more if (inline) { const commitHashes = new Set( result.entries.map((entry) => entry.full_hash) @@ -922,18 +1027,25 @@ export function GitLogTab() { ) } } catch (e) { + if (gen !== logGenRef.current) return if (isNotAGitRepoError(e)) { setNotAGitRepo(true) // Workspace state will flip isGitRepo within the next watch flush; // clear entries so stale log data does not linger while we wait. setEntries([]) + entriesRef.current = [] + setHasMore(false) + hasMoreRef.current = false } else { setError(toErrorMessage(e)) } } finally { - if (inline) { + // Only the latest generation owns the UI. Clear BOTH mode flags (so an + // inline refresh superseded by a full reload — or vice versa — can't + // latch a spinner/skeleton) and reopen appends. + if (gen === logGenRef.current) { + fetchingRef.current = false setRefreshing(false) - } else { setLoading(false) } } @@ -949,6 +1061,69 @@ export function GitLogTab() { void fetchLog({ inline: true }) }, [fetchLog]) + // Append the next page (older commits) via the backend `skip` offset. Reads + // live length/flags from refs; a generation mismatch means a page-0 reload + // (refresh / branch switch) superseded us, so the result is discarded. + const loadMore = useCallback(async () => { + if (!folder?.path) return + // Bail while a page-0 (re)load is in flight — its result resets the list, so + // the length captured here would request a wrong offset and drop commits. + // Single-flight via loadingMoreRef; fetchLog never touches that lock, so this + // call is its sole owner and releasing it below can't free a newer append. + if (fetchingRef.current || loadingMoreRef.current || !hasMoreRef.current) { + return + } + loadingMoreRef.current = true + setLoadingMore(true) + const gen = logGenRef.current + const skip = entriesRef.current.length + try { + const result = await gitLog( + folder.path, + PAGE_SIZE, + selectedBranch ?? undefined, + undefined, + skip + ) + if (gen !== logGenRef.current) return + const next = [...entriesRef.current, ...result.entries] + entriesRef.current = next + setEntries(next) + const more = result.entries.length >= PAGE_SIZE + hasMoreRef.current = more + setHasMore(more) + } catch { + // Stop auto-paginating on error; the manual refresh retries from page 0. + if (gen === logGenRef.current) { + hasMoreRef.current = false + setHasMore(false) + } + } finally { + loadingMoreRef.current = false + setLoadingMore(false) + } + }, [folder?.path, selectedBranch]) + + // virtua reports the scroll offset each frame; fetch the next page once the + // window nears the estimated end. + const handleVirtuaScroll = useCallback( + (offset: number) => { + const handle = virtualizerRef.current + if ( + !handle || + fetchingRef.current || + loadingMoreRef.current || + !hasMoreRef.current + ) { + return + } + if (offset + handle.viewportSize >= handle.scrollSize - LOAD_MORE_PX) { + void loadMore() + } + }, + [loadMore] + ) + const handleOpenNewBranchDialog = useCallback((entry: GitLogEntry) => { setNewBranchName("") setNewBranchTarget({ @@ -1103,12 +1278,6 @@ export function GitLogTab() { } }, [folder, refreshBranches, fetchLog]) - const handleScroll = useCallback((e: Event) => { - const target = e.target as HTMLElement - const nextScrolled = target.scrollTop > 0 - setScrolled((prev) => (prev === nextScrolled ? prev : nextScrolled)) - }, []) - if (!folder) { return } @@ -1117,27 +1286,41 @@ export function GitLogTab() { // render catches up to the switch (see the declaration above). if (loading || folderStale) { return ( - +
{hasBranches && ( - +
+ +
)} -
- {Array.from({ length: 5 }).map((_, i) => ( -
- - - -
- ))} -
- + +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ +
+ + + +
+
+ ))} +
+
+
) } @@ -1167,10 +1350,15 @@ export function GitLogTab() { ) } - if (error) { - return ( - - {hasBranches && ( + return ( +
+ {hasBranches && ( +
- )} -
-

{error}

-
- - ) - } - - if (entries.length === 0) { - return ( - -
- {hasBranches && ( - - )} -
-

- {t("noCommitsFound")} -

+ )} + {error ? ( + +
+

{error}

+
+
+ ) : entries.length === 0 ? ( +
+

+ {t("noCommitsFound")} +

- - ) - } - - return ( -
- - - -
- {hasBranches && ( -
+ + + {/* Full-bleed, virtualized commit timeline: one continuous rail, + windowed by virtua bound to the OverlayScrollbars viewport. + Older pages load on demand as the scroll nears the end (see + handleVirtuaScroll), so the whole history stays reachable. */} + {viewportEl ? ( + - -
- )} - {entries.map((entry) => { - const commitKey = entry.full_hash - const commitDate = parseDate(entry.date) - const pushStatus = getPushStatusMeta( - entry.pushed, - pushStatusLabels - ) - const PushStatusIcon = pushStatus.icon - const commitBranches = branchesByCommit[commitKey] - const isBranchLoading = !!branchesLoading[commitKey] - const branchError = branchesError[commitKey] - const isOpen = !!openByCommit[commitKey] - - return ( - - -
- { - setOpenByCommit((prev) => ({ - ...prev, - [commitKey]: open, - })) - if (open) { - void fetchCommitBranches(commitKey) - } - }} - open={isOpen} - > - - - - {entry.message} - - - - - - {entry.author} - - {formatRelativeTime(entry.date, t)} - - - {entry.hash} - - - - - - - - -
-
- - {t("hash")} - - - + {/* Push-status glyph leads the row — pushed / + not-pushed / unknown, tinted by + getPushStatusMeta. mt-0.5 centers it on the + first message line. */} + - {entry.full_hash} - - - - - {t("author")} - - - - {entry.author} - - - · + - - -
-
-

- {entry.message} -

- -
- {entry.files.length === 0 ? ( -
-

- {t("filesTitle")} -

-

- {t("noFileChangeDetails")} -

-
- ) : ( - - )} -
-

- {t("branchesTitle")} -

- {isBranchLoading ? ( -

- {t("loadingBranches")} -

- ) : branchError ? ( -

- {branchError} -

- ) : commitBranches && - commitBranches.length > 0 ? ( -
- {commitBranches.map((branch) => ( +
+ {/* Dim by default, darken on hover/open + (group = CommitHeader) instead of a hover + background — widens the readable area. + Single line: the subject. */} +

+ {entry.message} +

+
+ + {entry.author} + + + · + + + {/* Short hash pinned to this line's end: + a # glyph + the abbrev, no chip + background. ms-auto (not ml-auto) so it + stays on the trailing edge under RTL. */} - {branch} + + {entry.hash} - ))} +
- ) : ( -

- {t("noContainingBranches")} -

- )} -
+ + + +
+
+ + {t("hash")} + + + + {entry.full_hash} + + + + + {t("author")} + + + + {entry.author} + + + · + + + +
+
+

+ {entry.message} +

+ +
+ {entry.files.length === 0 ? ( +
+

+ {t("filesTitle")} +

+

+ {t("noFileChangeDetails")} +

+
+ ) : ( + + )} +
+

+ {t("branchesTitle")} +

+ {isBranchLoading ? ( +

+ {t("loadingBranches")} +

+ ) : branchError ? ( +

+ {branchError} +

+ ) : commitBranches && + commitBranches.length > 0 ? ( +
+ {commitBranches.map((branch) => ( + + {branch} + + ))} +
+ ) : ( +

+ {t("noContainingBranches")} +

+ )} +
+
+
+
- - + + + { + handleOpenNewBranchDialog(entry) + }} + > + + {t("newBranch")} + + { + void openCommitDiff( + entry.full_hash, + undefined, + entry.message + ) + }} + > + + {tCommon("viewDiff")} + + { + handleOpenResetDialog(entry) + }} + > + + {t("resetToHere")} + + {!isResetAllowed && ( + + {t("resetDisabledReasonNotCurrentBranchView")} + + )} + { + void fetchLog() + }} + > + + {tCommon("refresh")} + + { + if (!folder) return + openPushWindow(folder.id).catch((err) => { + const msg = toErrorMessage(err) + toast.error( + t("toasts.openPushWindowFailed"), + { + description: msg, + } + ) + }) + }} + > + + {tCommon("push")} + + +
- - - { - handleOpenNewBranchDialog(entry) - }} - > - - {t("newBranch")} - - { - void openCommitDiff( - entry.full_hash, - undefined, - entry.message - ) - }} - > - - {tCommon("viewDiff")} - - { - handleOpenResetDialog(entry) - }} - > - - {t("resetToHere")} - - {!isResetAllowed && ( - - {t("resetDisabledReasonNotCurrentBranchView")} - - )} - { - void fetchLog() - }} - > - - {tCommon("refresh")} - - { - if (!folder) return - openPushWindow(folder.id).catch((err) => { - const msg = toErrorMessage(err) - toast.error(t("toasts.openPushWindowFailed"), { - description: msg, - }) - }) - }} - > - - {tCommon("push")} - - - - ) - })} -
- -
- - { - void fetchLog() - }} - > - - {tCommon("refresh")} - - { - if (!folder) return - openPushWindow(folder.id).catch((err) => { - const msg = toErrorMessage(err) - toast.error(t("toasts.openPushWindowFailed"), { - description: msg, + ) + }} + + ) : null} + {loadingMore && ( +
+ +
+ )} + + + + { + void fetchLog() + }} + > + + {tCommon("refresh")} + + { + if (!folder) return + openPushWindow(folder.id).catch((err) => { + const msg = toErrorMessage(err) + toast.error(t("toasts.openPushWindowFailed"), { + description: msg, + }) }) - }) - }} - > - - {tCommon("push")} - - -
+ }} + > + + {tCommon("push")} + + + + )} { + it("parses a valid ISO string into a Date", () => { + const parsed = parseDate("2026-07-20T10:30:00.000Z") + expect(parsed).toBeInstanceOf(Date) + expect(parsed?.toISOString()).toBe("2026-07-20T10:30:00.000Z") + }) + + it("returns null for an unparseable string", () => { + expect(parseDate("not a date")).toBeNull() + }) +}) diff --git a/src/components/layout/git-log-timeline.ts b/src/components/layout/git-log-timeline.ts new file mode 100644 index 000000000..3a021d29a --- /dev/null +++ b/src/components/layout/git-log-timeline.ts @@ -0,0 +1,7 @@ +// Pure helpers behind the git-log timeline UI. Kept out of the component so they +// can be unit-tested without pulling the whole "use client" module graph. + +export function parseDate(dateStr: string): Date | null { + const date = new Date(dateStr) + return Number.isNaN(date.getTime()) ? null : date +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 93e4d7385..d5c9600d7 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1780,6 +1780,7 @@ "resetDisabledReasonNotCurrentBranchView": "متاح فقط عند عرض الفرع الحالي", "viewCommitDiffAria": "عرض diff للالتزام {hash}", "copyFullCommitHashAria": "نسخ hash الكامل للالتزام {hash}", + "changedFilesTitle": "تم تغيير {count} ملف", "pushStatus": { "pushed": "تم الدفع إلى البعيد", "notPushed": "لم يتم الدفع إلى البعيد", @@ -1802,6 +1803,8 @@ "resetFailed": "فشلت إعادة الضبط" }, "branchSelector": { + "searchBranch": "بحث عن فرع...", + "noBranches": "لا توجد فروع", "selectBranchPlaceholder": "اختر فرعًا...", "localBranches": "الفروع المحلية", "current": "الحالي", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index edff4f922..eb7a943c6 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1780,6 +1780,7 @@ "resetDisabledReasonNotCurrentBranchView": "Nur in der Ansicht des aktuellen Branches verfügbar", "viewCommitDiffAria": "Diff für Commit {hash} anzeigen", "copyFullCommitHashAria": "Vollständigen Commit-Hash {hash} kopieren", + "changedFilesTitle": "{count, plural, one {# Datei geändert} other {# Dateien geändert}}", "pushStatus": { "pushed": "Zum Remote gepusht", "notPushed": "Nicht zum Remote gepusht", @@ -1802,6 +1803,8 @@ "resetFailed": "Zurücksetzen fehlgeschlagen" }, "branchSelector": { + "searchBranch": "Branch suchen...", + "noBranches": "Keine Branches", "selectBranchPlaceholder": "Branch auswählen...", "localBranches": "Lokale Branches", "current": "Aktuell", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 182503e5c..6fa1a4a3a 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1780,6 +1780,7 @@ "resetDisabledReasonNotCurrentBranchView": "Available only when viewing current branch", "viewCommitDiffAria": "View diff for commit {hash}", "copyFullCommitHashAria": "Copy full commit hash {hash}", + "changedFilesTitle": "{count, plural, one {# file changed} other {# files changed}}", "pushStatus": { "pushed": "Pushed to remote", "notPushed": "Not pushed to remote", @@ -1802,6 +1803,8 @@ "resetFailed": "Reset failed" }, "branchSelector": { + "searchBranch": "Search branch...", + "noBranches": "No branches", "selectBranchPlaceholder": "Select branch...", "localBranches": "Local branches", "current": "Current", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 79ae3d89d..d792f0c60 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1780,6 +1780,7 @@ "resetDisabledReasonNotCurrentBranchView": "Disponible solo al ver la rama actual", "viewCommitDiffAria": "Ver diff del commit {hash}", "copyFullCommitHashAria": "Copiar hash completo del commit {hash}", + "changedFilesTitle": "{count, plural, one {# archivo modificado} other {# archivos modificados}}", "pushStatus": { "pushed": "Enviado al remoto", "notPushed": "No enviado al remoto", @@ -1802,6 +1803,8 @@ "resetFailed": "Falló el reset" }, "branchSelector": { + "searchBranch": "Buscar rama...", + "noBranches": "Sin ramas", "selectBranchPlaceholder": "Seleccionar rama...", "localBranches": "Ramas locales", "current": "Actual", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 345441311..5fb4bee38 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1780,6 +1780,7 @@ "resetDisabledReasonNotCurrentBranchView": "Disponible uniquement en affichant la branche actuelle", "viewCommitDiffAria": "Voir le diff du commit {hash}", "copyFullCommitHashAria": "Copier le hash complet du commit {hash}", + "changedFilesTitle": "{count, plural, one {# fichier modifié} other {# fichiers modifiés}}", "pushStatus": { "pushed": "Poussé vers le remote", "notPushed": "Non poussé vers le remote", @@ -1802,6 +1803,8 @@ "resetFailed": "Échec de la réinitialisation" }, "branchSelector": { + "searchBranch": "Rechercher une branche...", + "noBranches": "Aucune branche", "selectBranchPlaceholder": "Sélectionner une branche...", "localBranches": "Branches locales", "current": "Actuelle", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 5b2dfcb27..86cde7f7c 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1780,6 +1780,7 @@ "resetDisabledReasonNotCurrentBranchView": "現在のブランチ表示時のみ利用できます", "viewCommitDiffAria": "コミット {hash} の差分を表示", "copyFullCommitHashAria": "完全なコミットハッシュ {hash} をコピー", + "changedFilesTitle": "{count} 件のファイルを変更", "pushStatus": { "pushed": "リモートにプッシュ済み", "notPushed": "リモートに未プッシュ", @@ -1802,6 +1803,8 @@ "resetFailed": "リセットに失敗しました" }, "branchSelector": { + "searchBranch": "ブランチを検索...", + "noBranches": "ブランチがありません", "selectBranchPlaceholder": "ブランチを選択...", "localBranches": "ローカルブランチ", "current": "現在", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index af4e955a1..dc021ca44 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1780,6 +1780,7 @@ "resetDisabledReasonNotCurrentBranchView": "현재 브랜치 보기에서만 사용할 수 있습니다", "viewCommitDiffAria": "커밋 {hash}의 diff 보기", "copyFullCommitHashAria": "전체 커밋 해시 {hash} 복사", + "changedFilesTitle": "파일 {count}개 변경", "pushStatus": { "pushed": "원격에 푸시됨", "notPushed": "원격에 푸시되지 않음", @@ -1802,6 +1803,8 @@ "resetFailed": "리셋 실패" }, "branchSelector": { + "searchBranch": "브랜치 검색...", + "noBranches": "브랜치 없음", "selectBranchPlaceholder": "브랜치 선택...", "localBranches": "로컬 브랜치", "current": "현재", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 6e010b7ed..10edca791 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1780,6 +1780,7 @@ "resetDisabledReasonNotCurrentBranchView": "Disponível somente ao visualizar a branch atual", "viewCommitDiffAria": "Ver diff do commit {hash}", "copyFullCommitHashAria": "Copiar hash completo do commit {hash}", + "changedFilesTitle": "{count, plural, one {# arquivo alterado} other {# arquivos alterados}}", "pushStatus": { "pushed": "Enviado para o remoto", "notPushed": "Não enviado para o remoto", @@ -1802,6 +1803,8 @@ "resetFailed": "Falha no reset" }, "branchSelector": { + "searchBranch": "Pesquisar branch...", + "noBranches": "Nenhum branch", "selectBranchPlaceholder": "Selecionar branch...", "localBranches": "Branches locais", "current": "Atual", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 6823dd61c..12a3535d0 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1780,6 +1780,7 @@ "resetDisabledReasonNotCurrentBranchView": "仅在查看当前分支时可用", "viewCommitDiffAria": "查看提交 {hash} 的差异", "copyFullCommitHashAria": "复制完整提交哈希 {hash}", + "changedFilesTitle": "{count} 个文件更改", "pushStatus": { "pushed": "已推送到远程", "notPushed": "未推送到远程", @@ -1802,6 +1803,8 @@ "resetFailed": "重置失败" }, "branchSelector": { + "searchBranch": "搜索分支...", + "noBranches": "无分支", "selectBranchPlaceholder": "选择分支...", "localBranches": "本地分支", "current": "当前", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index d50a070dc..bfbe4bdff 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1780,6 +1780,7 @@ "resetDisabledReasonNotCurrentBranchView": "僅在檢視目前分支時可用", "viewCommitDiffAria": "查看提交 {hash} 的差異", "copyFullCommitHashAria": "複製完整提交雜湊 {hash}", + "changedFilesTitle": "{count} 個檔案變更", "pushStatus": { "pushed": "已推送到遠端", "notPushed": "未推送到遠端", @@ -1802,6 +1803,8 @@ "resetFailed": "重設失敗" }, "branchSelector": { + "searchBranch": "搜尋分支...", + "noBranches": "無分支", "selectBranchPlaceholder": "選擇分支...", "localBranches": "本地分支", "current": "目前", diff --git a/src/lib/api.ts b/src/lib/api.ts index 5da925755..a722e13e5 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -3035,13 +3035,15 @@ export async function gitLog( path: string, limit?: number, branch?: string, - remote?: string + remote?: string, + skip?: number ): Promise { return getTransport().call("git_log", { path, limit: limit ?? null, branch: branch ?? null, remote: remote ?? null, + skip: skip ?? null, }) } From b77c17304f824f9875335299740151b97cb65088 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 20 Jul 2026 21:43:56 +0800 Subject: [PATCH 7/9] fix(agents): add the cursor fields to the acp agent test fixtures The AcpAgentInfo test fixtures now set cursor_cli_config_json and cursor_settings, so they match the type and the project type-checks cleanly. --- src/components/chat/agent-selector.test.tsx | 2 ++ src/components/settings/acp-agent-settings.test.tsx | 2 ++ src/components/settings/codebuddy-config-panel.test.tsx | 2 ++ src/hooks/use-acp-agents.test.ts | 2 ++ 4 files changed, 8 insertions(+) diff --git a/src/components/chat/agent-selector.test.tsx b/src/components/chat/agent-selector.test.tsx index 71c788777..5c9082ce9 100644 --- a/src/components/chat/agent-selector.test.tsx +++ b/src/components/chat/agent-selector.test.tsx @@ -40,6 +40,8 @@ function agent( grok_settings: null, cline_secrets_json: null, hermes_config_yaml: null, + cursor_cli_config_json: null, + cursor_settings: null, model_provider_id: null, ...overrides, } diff --git a/src/components/settings/acp-agent-settings.test.tsx b/src/components/settings/acp-agent-settings.test.tsx index 16b77e0b1..e42f89d37 100644 --- a/src/components/settings/acp-agent-settings.test.tsx +++ b/src/components/settings/acp-agent-settings.test.tsx @@ -34,6 +34,8 @@ function makeAgent(overrides: Partial): AcpAgentInfo { grok_config_toml: null, grok_settings: null, hermes_config_yaml: null, + cursor_cli_config_json: null, + cursor_settings: null, model_provider_id: null, ...overrides, } diff --git a/src/components/settings/codebuddy-config-panel.test.tsx b/src/components/settings/codebuddy-config-panel.test.tsx index b757de825..2f01b1a71 100644 --- a/src/components/settings/codebuddy-config-panel.test.tsx +++ b/src/components/settings/codebuddy-config-panel.test.tsx @@ -34,6 +34,8 @@ function makeAgent(env: Record): AcpAgentInfo { grok_settings: null, cline_secrets_json: null, hermes_config_yaml: null, + cursor_cli_config_json: null, + cursor_settings: null, model_provider_id: null, } } diff --git a/src/hooks/use-acp-agents.test.ts b/src/hooks/use-acp-agents.test.ts index b758fa403..5537c2846 100644 --- a/src/hooks/use-acp-agents.test.ts +++ b/src/hooks/use-acp-agents.test.ts @@ -57,6 +57,8 @@ function makeAgent(agentType: AgentType, sortOrder: number): AcpAgentInfo { grok_settings: null, cline_secrets_json: null, hermes_config_yaml: null, + cursor_cli_config_json: null, + cursor_settings: null, model_provider_id: null, } } From 0ce209508c3917722f004a3fd111c2f6693d1179 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 20 Jul 2026 21:44:08 +0800 Subject: [PATCH 8/9] chore(i18n): remove unused git-log tab message keys Drop the viewCommitDiffAria and changedFilesTitle keys from every locale; nothing references them after the git-log tab redesign. --- src/i18n/messages/ar.json | 2 -- src/i18n/messages/de.json | 2 -- src/i18n/messages/en.json | 2 -- src/i18n/messages/es.json | 2 -- src/i18n/messages/fr.json | 2 -- src/i18n/messages/ja.json | 2 -- src/i18n/messages/ko.json | 2 -- src/i18n/messages/pt.json | 2 -- src/i18n/messages/zh-CN.json | 2 -- src/i18n/messages/zh-TW.json | 2 -- 10 files changed, 20 deletions(-) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index d5c9600d7..b53059d0e 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1778,9 +1778,7 @@ "newBranch": "فرع جديد...", "resetToHere": "إعادة الضبط إلى هنا", "resetDisabledReasonNotCurrentBranchView": "متاح فقط عند عرض الفرع الحالي", - "viewCommitDiffAria": "عرض diff للالتزام {hash}", "copyFullCommitHashAria": "نسخ hash الكامل للالتزام {hash}", - "changedFilesTitle": "تم تغيير {count} ملف", "pushStatus": { "pushed": "تم الدفع إلى البعيد", "notPushed": "لم يتم الدفع إلى البعيد", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index eb7a943c6..202a84c4a 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1778,9 +1778,7 @@ "newBranch": "Neuer Branch...", "resetToHere": "Auf diesen Stand zurücksetzen", "resetDisabledReasonNotCurrentBranchView": "Nur in der Ansicht des aktuellen Branches verfügbar", - "viewCommitDiffAria": "Diff für Commit {hash} anzeigen", "copyFullCommitHashAria": "Vollständigen Commit-Hash {hash} kopieren", - "changedFilesTitle": "{count, plural, one {# Datei geändert} other {# Dateien geändert}}", "pushStatus": { "pushed": "Zum Remote gepusht", "notPushed": "Nicht zum Remote gepusht", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 6fa1a4a3a..de9e70b52 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1778,9 +1778,7 @@ "newBranch": "New branch...", "resetToHere": "Reset to Here", "resetDisabledReasonNotCurrentBranchView": "Available only when viewing current branch", - "viewCommitDiffAria": "View diff for commit {hash}", "copyFullCommitHashAria": "Copy full commit hash {hash}", - "changedFilesTitle": "{count, plural, one {# file changed} other {# files changed}}", "pushStatus": { "pushed": "Pushed to remote", "notPushed": "Not pushed to remote", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index d792f0c60..088d56106 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1778,9 +1778,7 @@ "newBranch": "Nueva rama...", "resetToHere": "Resetear aquí", "resetDisabledReasonNotCurrentBranchView": "Disponible solo al ver la rama actual", - "viewCommitDiffAria": "Ver diff del commit {hash}", "copyFullCommitHashAria": "Copiar hash completo del commit {hash}", - "changedFilesTitle": "{count, plural, one {# archivo modificado} other {# archivos modificados}}", "pushStatus": { "pushed": "Enviado al remoto", "notPushed": "No enviado al remoto", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 5fb4bee38..362055393 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1778,9 +1778,7 @@ "newBranch": "Nouvelle branche...", "resetToHere": "Réinitialiser ici", "resetDisabledReasonNotCurrentBranchView": "Disponible uniquement en affichant la branche actuelle", - "viewCommitDiffAria": "Voir le diff du commit {hash}", "copyFullCommitHashAria": "Copier le hash complet du commit {hash}", - "changedFilesTitle": "{count, plural, one {# fichier modifié} other {# fichiers modifiés}}", "pushStatus": { "pushed": "Poussé vers le remote", "notPushed": "Non poussé vers le remote", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 86cde7f7c..5b167d284 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1778,9 +1778,7 @@ "newBranch": "新規ブランチ...", "resetToHere": "ここにリセット", "resetDisabledReasonNotCurrentBranchView": "現在のブランチ表示時のみ利用できます", - "viewCommitDiffAria": "コミット {hash} の差分を表示", "copyFullCommitHashAria": "完全なコミットハッシュ {hash} をコピー", - "changedFilesTitle": "{count} 件のファイルを変更", "pushStatus": { "pushed": "リモートにプッシュ済み", "notPushed": "リモートに未プッシュ", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index dc021ca44..f0171fe98 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1778,9 +1778,7 @@ "newBranch": "새 브랜치...", "resetToHere": "여기로 리셋", "resetDisabledReasonNotCurrentBranchView": "현재 브랜치 보기에서만 사용할 수 있습니다", - "viewCommitDiffAria": "커밋 {hash}의 diff 보기", "copyFullCommitHashAria": "전체 커밋 해시 {hash} 복사", - "changedFilesTitle": "파일 {count}개 변경", "pushStatus": { "pushed": "원격에 푸시됨", "notPushed": "원격에 푸시되지 않음", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 10edca791..4d2008184 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1778,9 +1778,7 @@ "newBranch": "Nova branch...", "resetToHere": "Resetar para aqui", "resetDisabledReasonNotCurrentBranchView": "Disponível somente ao visualizar a branch atual", - "viewCommitDiffAria": "Ver diff do commit {hash}", "copyFullCommitHashAria": "Copiar hash completo do commit {hash}", - "changedFilesTitle": "{count, plural, one {# arquivo alterado} other {# arquivos alterados}}", "pushStatus": { "pushed": "Enviado para o remoto", "notPushed": "Não enviado para o remoto", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 12a3535d0..7bd2e4087 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1778,9 +1778,7 @@ "newBranch": "新建分支...", "resetToHere": "重置到此处", "resetDisabledReasonNotCurrentBranchView": "仅在查看当前分支时可用", - "viewCommitDiffAria": "查看提交 {hash} 的差异", "copyFullCommitHashAria": "复制完整提交哈希 {hash}", - "changedFilesTitle": "{count} 个文件更改", "pushStatus": { "pushed": "已推送到远程", "notPushed": "未推送到远程", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index bfbe4bdff..dd21a4111 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1778,9 +1778,7 @@ "newBranch": "新增分支...", "resetToHere": "重設到此處", "resetDisabledReasonNotCurrentBranchView": "僅在檢視目前分支時可用", - "viewCommitDiffAria": "查看提交 {hash} 的差異", "copyFullCommitHashAria": "複製完整提交雜湊 {hash}", - "changedFilesTitle": "{count} 個檔案變更", "pushStatus": { "pushed": "已推送到遠端", "notPushed": "未推送到遠端", From 333278de7eb824291eca90690c92d56f02aea430 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 20 Jul 2026 22:44:51 +0800 Subject: [PATCH 9/9] # Release version 0.21.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat(git-log): **The Commit history tab is now a continuous timeline.** History renders as one virtualized timeline that loads older commits on demand as you scroll, so the whole log stays reachable — not just the first page. - feat(file-tree): **Drive the file tree from the keyboard.** Arrow keys move, expand, and collapse the selection, Enter or Space opens a file or toggles a folder, and Home/End jump to the ends — with the selected row kept in view. - feat(grok): **Choose how Grok authenticates.** Pick an official subscription (`grok login`), an XAI_API_KEY, or a custom endpoint; the panel shows only the credential that method needs and recognizes your current one from the saved config. - feat(workspace): **Frosted surfaces over a background image.** With a background image enabled, inline code, message bubbles, delegation cards, and Monaco's sticky-scroll header turn to frosted glass instead of opaque blocks. - feat(workspace): **A tidier titlebar at any zoom.** The browser-style tabs draw at equal width under a continuous arch, and the window controls no longer overflow when the app is zoomed in. - feat(workspace): **Remote windows show the workspace name.** A remote-desktop window's status bar now shows the connected workspace's name — with its address on hover — beside the session count. - fix(search): **The file search dialog finds deeply-nested files.** A gitignore-aware backend walk reaches files at any depth without descending into node_modules or target, and fuzzy ranking surfaces deep matches. The composer's @-mention picker benefits too. - fix(workspace): **Cursor and Grok delegation cards show their icon again.** They no longer render a blank icon for those sub-agent types. ----------------------------- # 发布版本 0.21.2 - 功能(提交历史):**「提交」标签现在是一条连续的时间线。** 历史以单条虚拟化的时间线呈现,滚动时按需加载更早的提交,整段历史都可触达,而不再只有第一页。 - 功能(文件树):**用键盘操作文件树。** 方向键移动、展开、收起选中项,Enter 或 Space 打开文件或折叠目录,Home/End 跳到首尾,选中行始终保持在可视范围内。 - 功能(Grok):**自选 Grok 的鉴权方式。** 可在官方订阅(`grok login`)、XAI_API_KEY、自定义端点之间选择;面板只显示所选方式需要的凭据,并从已保存的配置识别你当前的方式。 - 功能(工作台):**背景图之上的磨砂表面。** 启用背景图后,内联代码、消息气泡、委派卡片与 Monaco 吸顶滚动头会变为磨砂玻璃,而非不透明色块。 - 功能(工作台):**任意缩放下更整洁的标题栏。** 浏览器式标签以等宽绘制、底部连成一道拱形,放大应用时窗口控件也不再溢出。 - 功能(工作台):**远程窗口显示工作区名称。** 远程桌面窗口的状态栏现在会在会话计数旁显示所连工作区的名称——悬停可见其地址。 - 修复(搜索):**文件搜索对话框能找到深层嵌套的文件。** 后端按 gitignore 规则遍历,不进入 node_modules 或 target 即可触达任意深度的文件,模糊排序让深层匹配浮现。输入框的 @ 提及选择器也同样受益。 - 修复(工作台):**Cursor 与 Grok 的委派卡片重新显示图标。** 这两类子智能体不再显示空白图标。 --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index ce84ebec0..66f92dadc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codeg", "private": true, - "version": "0.21.1", + "version": "0.21.2", "packageManager": "pnpm@11.9.0", "scripts": { "dev": "next dev --turbopack", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b1ea0e131..60d7f8a7b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1014,7 +1014,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "codeg" -version = "0.21.1" +version = "0.21.2" dependencies = [ "aes-gcm", "agent-client-protocol-schema", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d0be4727f..232b6bd11 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codeg" -version = "0.21.1" +version = "0.21.2" description = "Agent Code Generation App" authors = ["feitao"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ac685facd..8785ad802 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.1", + "version": "0.21.2", "identifier": "app.codeg", "build": { "beforeDevCommand": "pnpm tauri:before-dev",