Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/agent-gateway/internal/handler/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ func normalizeTrimmedText(value string) string {
func NormalizeExecutionMode(value string) string {
normalized := normalizeTrimmedText(value)
switch normalized {
case "tools", "agent-dev":
case "tools", "agent-dev", "readonly":
return normalized
default:
return "text"
Expand Down
1 change: 1 addition & 0 deletions crates/agent-gateway/internal/handler/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ func TestNormalizeExecutionMode(t *testing.T) {
cases := map[string]string{
"": "text",
" text ": "text",
"readonly": "readonly",
"tools": "tools",
"agent-dev": "agent-dev",
"unknown": "text",
Expand Down
12 changes: 12 additions & 0 deletions crates/agent-gateway/test/webui/web-settings.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1466,3 +1466,15 @@ test("web right dock migrates the legacy tabs shape", () => {
assert.deepEqual(project.tabOrder, ["sess-1", RIGHT_DOCK_TAB_IDS.fileTree]);
assert.equal(project.activeTabId, "sess-1");
});

test("web settings preserve readonly execution mode", () => {
installWindow();
const defaults = settings.getDefaultSettings();
const normalized = settings.normalizeSettings({
system: { ...defaults.system, executionMode: "readonly" },
});

assert.equal(normalized.system.executionMode, "readonly");
assert.equal(settings.isAgentExecutionMode("readonly"), true);
assert.equal(settings.isReadOnlyExecutionMode("readonly"), true);
});
10 changes: 10 additions & 0 deletions crates/agent-gateway/web/src/app/GatewayApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import { toModelValue } from "@/lib/providers/llm";
import {
type ChatRuntimeControls,
DEFAULT_WORKSPACE_PROJECT_ID,
type ExecutionMode,
findProviderModelConfig,
getChatRuntimeReasoningLevelsForProvider,
getNextTheme,
Expand All @@ -92,6 +93,7 @@ import {
updateRightDockWidth,
updateSkills,
updateSshProjectHostIds,
updateSystem,
type WorkspaceProject,
workspaceProjectPathKey,
} from "@/lib/settings";
Expand Down Expand Up @@ -3397,6 +3399,12 @@ export default function GatewayApp() {
activeSelectedModel?.model,
],
);
const handleExecutionModeChange = useCallback(
(executionMode: ExecutionMode) => {
setSettings((prev) => updateSystem(prev, { executionMode }));
},
[setSettings],
);
const isAgentDevExecutionMode = isAgentDevMode(settings.system.executionMode);

const modelOptions = useMemo(
Expand Down Expand Up @@ -4226,6 +4234,8 @@ export default function GatewayApp() {
workdir={displayedConversationWorkdir}
enabledSkills={enabledComposerSkills}
isAgentMode={isAgentMode}
executionMode={settings.system.executionMode}
onExecutionModeChange={handleExecutionModeChange}
chatRuntimeControls={chatRuntimeControlsForCurrentProvider}
reasoningOptions={chatRuntimeReasoningOptions}
thinkingAlwaysOn={chatRuntimeThinkingAlwaysOn}
Expand Down
12 changes: 12 additions & 0 deletions crates/agent-gateway/web/src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1029,13 +1029,18 @@ export const translations: Record<Locale, Record<string, string>> = {
"settings.fontSizeLarge": "大",
"settings.fontSizeXLarge": "特大",
"settings.executionMode": "执行模式",
"settings.executionModeDesc": "选择新对话的默认权限;当前对话也可从输入框切换。",
"settings.chatMode": "Chat 模式",
"settings.chatModeDesc": "纯文本对话,模型只输出文本与 Markdown",
"settings.readonlyAgentMode": "只读 Agent",
"settings.readonlyAgentModeDesc": "可检查项目并委派只读代理,不修改文件或外部系统",
"settings.agentMode": "Agent 模式",
"settings.agentModeDesc": "允许调用工具,执行文件与命令行操作",
"settings.agentDevMode": "Agent dev 模式",
"settings.agentDevModeDesc":
"与 Agent 模式一致,但会把当前对话的流式请求与返回逐行写入 ~/.liveagent/debug/<对话ID>.jsonl",
"settings.agentTrace": "Agent 调试记录",
"settings.agentTraceDesc": "仅在 Agent 模式启用时写入逐行 JSONL 调试记录",
"settings.workdir": "项目文件夹",
"settings.workdirRequired": "必填",
"settings.workdirDesc": "选择这个项目的文件夹。文件工具会在所选项目目录内读写/搜索。",
Expand Down Expand Up @@ -2814,13 +2819,20 @@ export const translations: Record<Locale, Record<string, string>> = {
"settings.fontSizeLarge": "Large",
"settings.fontSizeXLarge": "Extra Large",
"settings.executionMode": "Execution Mode",
"settings.executionModeDesc":
"Choose the default permission for new conversations. The current conversation can also be changed from the main composer.",
"settings.chatMode": "Chat Mode",
"settings.chatModeDesc": "Plain text conversation, model outputs text and Markdown only",
"settings.readonlyAgentMode": "Read-only Agent",
"settings.readonlyAgentModeDesc":
"Can inspect the project and delegate read-only agents without modifying files or external systems",
"settings.agentMode": "Agent Mode",
"settings.agentModeDesc": "Allows tool calls, file and command-line operations",
"settings.agentDevMode": "Agent Dev Mode",
"settings.agentDevModeDesc":
"Same as Agent mode, but also writes each streaming request and response line-by-line to ~/.liveagent/debug/<conversationId>.jsonl",
"settings.agentTrace": "Agent debug trace",
"settings.agentTraceDesc": "Writes line-delimited JSONL traces only while Agent mode is active",
"settings.workdir": "Project Folder",
"settings.workdirRequired": "Required",
"settings.workdirDesc":
Expand Down
7 changes: 6 additions & 1 deletion crates/agent-gateway/web/src/lib/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export type { SystemToolId } from "../tools/systemToolOptions";

export type ProviderId = "codex" | "claude_code" | "gemini";

export type ExecutionMode = "text" | "tools" | "agent-dev";
export type ExecutionMode = "text" | "readonly" | "tools" | "agent-dev";

export type CodexRequestFormat = "openai-completions" | "openai-responses";

Expand Down Expand Up @@ -408,6 +408,7 @@ export function getBuiltinCustomProviders(): CustomProvider[] {
function normalizeExecutionMode(input: unknown): ExecutionMode {
switch (input) {
case "text":
case "readonly":
case "tools":
case "agent-dev":
return input;
Expand All @@ -420,6 +421,10 @@ export function isAgentExecutionMode(mode: ExecutionMode): boolean {
return mode !== "text";
}

export function isReadOnlyExecutionMode(mode: ExecutionMode): boolean {
return mode === "readonly";
}

export function isAgentDevMode(mode: ExecutionMode): boolean {
return mode === "agent-dev";
}
Expand Down
68 changes: 67 additions & 1 deletion crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,16 @@ import {
Lightbulb,
LightbulbOff,
Loader2,
MessageSquare,
Paperclip,
Play,
Send,
Shield,
Sparkle,
Square,
SquarePen,
Trash2,
Wrench,
X,
} from "../../components/icons";
import { Button } from "../../components/ui/button";
Expand All @@ -47,6 +50,7 @@ import type { GitClient } from "../../lib/git/types";
import {
type ChatRuntimeControls,
DEFAULT_CHAT_RUNTIME_CONTROLS,
type ExecutionMode,
type ReasoningLevel,
} from "../../lib/settings";
import { cn } from "../../lib/shared/utils";
Expand Down Expand Up @@ -115,6 +119,8 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
workdir: string;
enabledSkills: MentionComposerSkill[];
isAgentMode: boolean;
executionMode: ExecutionMode;
onExecutionModeChange: (mode: ExecutionMode) => void;
chatRuntimeControls: ChatRuntimeControls;
reasoningOptions: ReasoningLevel[];
thinkingAlwaysOn: boolean;
Expand Down Expand Up @@ -148,6 +154,8 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
workdir,
enabledSkills,
isAgentMode,
executionMode,
onExecutionModeChange,
chatRuntimeControls,
reasoningOptions,
thinkingAlwaysOn,
Expand Down Expand Up @@ -187,7 +195,9 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
const [queueScrollbar, setQueueScrollbar] = useState<QueueScrollbarState>(
DEFAULT_QUEUE_SCROLLBAR_STATE,
);
const uploadDisabled = isInputDisabled || isUploadingFiles || !isAgentMode || !workdir;
const visibleExecutionMode = executionMode === "agent-dev" ? "tools" : executionMode;
const uploadDisabled =
isInputDisabled || isUploadingFiles || visibleExecutionMode !== "tools" || !workdir;
const controlsDisabled = isInputDisabled;
const hasSendableDraft = !composerIsEmpty || pendingUploadedFiles.length > 0;
const thinkingSupported = reasoningOptions.length > 0;
Expand All @@ -213,6 +223,18 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
: t("chat.runtime.thinkingTooltip");
const webSearchTooltip = t("chat.runtime.webSearchTooltip");
const toggleQueueTooltip = queueCollapsed ? t("chat.queue.expand") : t("chat.queue.collapse");
const executionModes: Array<{
mode: "text" | "readonly" | "tools";
label: string;
icon: typeof MessageSquare;
}> = [
{ mode: "text", label: t("settings.chatMode"), icon: MessageSquare },
{ mode: "readonly", label: t("settings.readonlyAgentMode"), icon: Shield },
{ mode: "tools", label: t("settings.agentMode"), icon: Wrench },
];
const selectedExecutionMode =
executionModes.find((option) => option.mode === visibleExecutionMode) ?? executionModes[2];
const SelectedExecutionModeIcon = selectedExecutionMode.icon;

const toggleQueueCollapsed = useCallback(() => {
setQueueCollapsed((current) => !current);
Expand Down Expand Up @@ -620,6 +642,50 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {

<div className="relative flex items-center justify-between gap-2 px-3 pb-2 pt-1">
<div className="flex min-w-0 flex-1 items-center gap-1">
<Select
value={visibleExecutionMode}
onValueChange={(value) =>
onExecutionModeChange(value as "text" | "readonly" | "tools")
}
disabled={controlsDisabled}
>
<SelectTrigger
aria-label={`${t("settings.executionMode")}: ${selectedExecutionMode.label}`}
title={selectedExecutionMode.label}
className={cn(
"mr-1 h-8 w-auto max-w-[7.5rem] shrink-0 gap-1 rounded-full border-black/[0.055] bg-white/45 px-2 py-0 text-xs font-medium shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-hidden transition-colors hover:bg-white/70 disabled:pointer-events-none dark:border-white/[0.08] dark:bg-white/[0.04] dark:hover:bg-white/[0.08] max-sm:mr-0 max-sm:w-8 max-sm:gap-0 max-sm:px-0 max-sm:[&_svg:last-child]:hidden [&_svg:last-child]:h-3 [&_svg:last-child]:w-3 [&_svg:last-child]:shrink-0 [&_svg:last-child]:opacity-45 [&_svg:last-child]:transition-transform [&[data-popup-open]_svg:last-child]:rotate-180",
visibleExecutionMode === "readonly" &&
"border-emerald-500/15 bg-emerald-50/70 text-emerald-700 hover:bg-emerald-50 dark:border-emerald-300/10 dark:bg-emerald-400/[0.10] dark:text-emerald-200 dark:hover:bg-emerald-400/[0.15]",
)}
>
<SelectValue>
<span className="flex min-w-0 items-center gap-1.5">
<SelectedExecutionModeIcon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate max-sm:hidden">{selectedExecutionMode.label}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent className="min-w-40 border-black/[0.08] bg-popover/95 shadow-xl backdrop-blur-xl dark:border-white/[0.10]">
{executionModes.map((option) => {
const Icon = option.icon;
return (
<SelectItem key={option.mode} value={option.mode} className="py-2 pr-8">
<span className="flex items-center gap-2">
<Icon
className={cn(
"h-4 w-4 text-muted-foreground",
option.mode === "readonly" &&
"text-emerald-600 dark:text-emerald-300",
)}
/>
<span>{option.label}</span>
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>

<RuntimeControlTooltip label={uploadTooltip}>
<button
type="button"
Expand Down
Loading
Loading