Skip to content
Open
2 changes: 1 addition & 1 deletion crates/agent-gateway/internal/protocol/pbws/guard.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ func vetChatFileOpen(req *gatewayv2.ChatFileOpenRequest) error {
// enable_web_git 门控,读操作(status/log/diff 等)始终放行。
func gitActionIsWrite(action string) bool {
switch action {
case "clone", "clone_start", "clone_cancel", "clone_dismiss", "init", "switch_branch", "create_branch", "stage", "stage_all", "unstage", "unstage_all", "discard", "discard_all", "add_to_gitignore", "commit", "fetch", "pull", "set_remote", "push", "delete_branch", "rename_branch", "stash_push", "stash_pop":
case "clone", "clone_start", "clone_cancel", "clone_dismiss", "init", "switch_branch", "create_branch", "create_worktree", "stage", "stage_all", "unstage", "unstage_all", "discard", "discard_all", "add_to_gitignore", "commit", "fetch", "pull", "set_remote", "push", "delete_branch", "rename_branch", "remove_worktree", "stash_push", "stash_pop":
return true
default:
return false
Expand Down
12 changes: 7 additions & 5 deletions crates/agent-gateway/test/websocket/v2_git_gating_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func TestV2GitRejectsWriteRequestsWhenDisabled(t *testing.T) {
_, _, conn, cleanup := newV2GitBrowserTest(t, false)
defer cleanup()

for _, action := range []string{"clone", "stage", "init", "stage_all", "unstage_all", "discard_all", "push", "commit"} {
for _, action := range []string{"clone", "stage", "init", "create_worktree", "remove_worktree", "stage_all", "unstage_all", "discard_all", "push", "commit"} {
id := "git-disabled-" + action
sendGitAgentRequest(t, conn, id, action)

Expand Down Expand Up @@ -99,10 +99,12 @@ func TestV2GitAllowsWriteRequestsWhenEnabled(t *testing.T) {
_, agentSession, conn, cleanup := newV2GitBrowserTest(t, true)
defer cleanup()

sendGitAgentRequest(t, conn, "git-stage-1", "stage")
for _, action := range []string{"stage", "create_worktree", "remove_worktree"} {
sendGitAgentRequest(t, conn, "git-write-"+action, action)

outbound := readOutboundEnvelope(t, agentSession)
if outbound.GetGitRequest().GetAction() != "stage" {
t.Fatalf("outbound = %#v, want forwarded git stage request", outbound)
outbound := readOutboundEnvelope(t, agentSession)
if outbound.GetGitRequest().GetAction() != action {
t.Fatalf("outbound = %#v, want forwarded git %s request", outbound, action)
}
}
}
46 changes: 46 additions & 0 deletions crates/agent-gateway/test/webui/gateway-git-client.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createWebModuleLoader } from "../helpers/load-web-module.mjs";

const loader = createWebModuleLoader();
const { createGatewayGitClient } = loader.loadModule("src/lib/git/gatewayGitClient.ts");

test("gateway git client forwards worktree create and remove operations", async () => {
const calls = [];
const api = {
async gitRequest(action, workdir, args) {
calls.push({ action, workdir, args });
if (action === "create_worktree") {
return { ok: true, worktreePath: "/workspace/.worktrees/topic" };
}
return { ok: true };
},
};
const client = createGatewayGitClient(api);

const created = await client.createWorktree("/workspace/project", "topic", "main");
await client.removeWorktree(
"/workspace/project",
"/workspace/.worktrees/topic",
true,
"topic",
);

assert.equal(created.worktreePath, "/workspace/.worktrees/topic");
assert.deepEqual(calls, [
{
action: "create_worktree",
workdir: "/workspace/project",
args: { name: "topic", startPoint: "main" },
},
{
action: "remove_worktree",
workdir: "/workspace/project",
args: {
worktreePath: "/workspace/.worktrees/topic",
force: true,
deleteBranch: "topic",
},
},
]);
});
37 changes: 37 additions & 0 deletions crates/agent-gateway/test/webui/web-settings.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,43 @@ const chatHelpers = loader.loadModule("@/lib/chat/chatPageHelpers.ts");
const adminApi = loader.loadModule("@/lib/adminApi.ts");
const RIGHT_DOCK_TAB_IDS = settings.RIGHT_DOCK_SINGLETON_TAB_IDS;

test("web settings normalize and preserve workspace project groups", () => {
const normalized = settings.normalizeSettings({
system: {
workspaceProjectGroups: [
{
id: " source-group ",
name: " Source ",
projectPaths: [" /workspace/project ", "/workspace/project", "/workspace/topic"],
sourceProjectPath: " /workspace/project ",
collapsed: true,
createdAt: 100,
updatedAt: 200,
},
{ id: "source-group", name: "duplicate", projectPaths: [] },
],
},
});

assert.deepEqual(normalized.system.workspaceProjectGroups, [
{
id: "source-group",
name: "Source",
projectPaths: ["/workspace/project", "/workspace/topic"],
sourceProjectPath: "/workspace/project",
collapsed: true,
createdAt: 100,
updatedAt: 200,
},
]);

const update = settingsSync.buildGatewaySettingsSyncUpdatePayload(
settings.normalizeSettings({}),
normalized,
);
assert.deepEqual(update.system.workspaceProjectGroups, normalized.system.workspaceProjectGroups);
});

test("custom provider normalization defaults and filters ordered custom headers", () => {
assert.deepEqual(settings.normalizeCustomProvider({}).customHeaders, []);

Expand Down
131 changes: 131 additions & 0 deletions crates/agent-gateway/web/src/app/GatewayApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,13 @@ import { sortSidebarConversations } from "@liveagent/ui/lib/sidebar/reconcile";
import { createSidebarStore } from "@liveagent/ui/lib/sidebar/store";
import { useSidebarSelector } from "@liveagent/ui/lib/sidebar/useSidebarSelector";
import {
assignWorkspaceProjectToGroup,
ensureWorktreeProjectGroup,
fallbackWorkspaceProjectName,
findWorkspaceProject,
mergeWorkspaceProjectsWithHistory,
} from "@liveagent/ui/lib/workspaceProjects";
import type { WorkspaceProjectGroup } from "@liveagent/ui/lib/workspaceProjectTypes";
import { FloorNavRail } from "@liveagent/ui/pages/chat/transcript/FloorNavRail";
import {
CHAT_TRANSCRIPT_WIDTH_CSS_VAR,
Expand Down Expand Up @@ -1430,6 +1434,126 @@ export default function GatewayApp() {
[activateWorkspaceProject, sidebarStore],
);

const handleOpenWorktree = useCallback(
(path: string) => {
const trimmed = path.trim();
const worktreeKey = workspaceProjectPathKey(trimmed);
const sourceProjectPath = displayedConversationWorkdirRef.current.trim();
if (!trimmed || !worktreeKey || !sourceProjectPath) return;
activateWorkspaceProject(createWorkspaceProjectFromPath(trimmed, "managed"));
setSettings((prev) => {
const autoGroup = prev.system.workspaceProjectGroups.find(
(group) =>
group.sourceProjectPath &&
group.projectPaths.some(
(memberPath) =>
workspaceProjectPathKey(memberPath) === workspaceProjectPathKey(sourceProjectPath),
),
);
const sourcePath = autoGroup?.sourceProjectPath ?? sourceProjectPath;
const sourceProject = prev.system.workspaceProjects.find(
(project) =>
workspaceProjectPathKey(project.path) === workspaceProjectPathKey(sourcePath),
);
const ensured = ensureWorktreeProjectGroup(prev.system.workspaceProjectGroups, {
name: sourceProject?.name || fallbackWorkspaceProjectName(sourcePath),
sourceProjectPath: sourcePath,
});
let workspaceProjectGroups = assignWorkspaceProjectToGroup(
ensured.groups,
ensured.groupId,
sourcePath,
);
workspaceProjectGroups = assignWorkspaceProjectToGroup(
workspaceProjectGroups,
ensured.groupId,
trimmed,
);
return { ...prev, system: { ...prev.system, workspaceProjectGroups } };
});
void sidebarStore.refreshWorkdirs("new-workdir");
},
[activateWorkspaceProject, setSettings, sidebarStore],
);

const updateWorkspaceProjectGroups = useCallback(
(updater: (groups: WorkspaceProjectGroup[]) => WorkspaceProjectGroup[]) => {
setSettings((prev) => {
const next = updater(prev.system.workspaceProjectGroups);
if (next === prev.system.workspaceProjectGroups) return prev;
return { ...prev, system: { ...prev.system, workspaceProjectGroups: next } };
});
},
[setSettings],
);

const handleCreateWorkspaceGroup = useCallback(
(nameInput: string) => {
const name = nameInput.trim();
if (!name) return;
const now = Date.now();
updateWorkspaceProjectGroups((groups) => [
...groups,
{ id: createUuid(), name, projectPaths: [], createdAt: now, updatedAt: now },
]);
},
[updateWorkspaceProjectGroups],
);

const handleRenameWorkspaceGroup = useCallback(
(groupId: string, nameInput: string) => {
const name = nameInput.trim();
if (!name) return;
updateWorkspaceProjectGroups((groups) =>
groups.map((group) =>
group.id === groupId ? { ...group, name, updatedAt: Date.now() } : group,
),
);
},
[updateWorkspaceProjectGroups],
);

const handleDeleteWorkspaceGroup = useCallback(
(groupId: string) => {
updateWorkspaceProjectGroups((groups) => groups.filter((group) => group.id !== groupId));
},
[updateWorkspaceProjectGroups],
);

const handleMoveWorkspaceProjectToGroup = useCallback(
(projectPath: string, groupId: string | null) => {
const pathKey = workspaceProjectPathKey(projectPath);
if (!pathKey) return;
updateWorkspaceProjectGroups((groups) => {
if (groupId === null) {
return groups.map((group) => {
const projectPaths = group.projectPaths.filter(
(path) => workspaceProjectPathKey(path) !== pathKey,
);
return projectPaths.length === group.projectPaths.length
? group
: { ...group, projectPaths, updatedAt: Date.now() };
});
}
return assignWorkspaceProjectToGroup(groups, groupId, projectPath);
});
},
[updateWorkspaceProjectGroups],
);

const handleToggleWorkspaceGroupCollapsed = useCallback(
(groupId: string) => {
updateWorkspaceProjectGroups((groups) =>
groups.map((group) =>
group.id === groupId
? { ...group, collapsed: !group.collapsed, updatedAt: Date.now() }
: group,
),
);
},
[updateWorkspaceProjectGroups],
);

const commitWorkspaceProjectRename = useCallback(
(project: WorkspaceProject, nextNameInput: string) => {
if (project.id === DEFAULT_WORKSPACE_PROJECT_ID) return;
Expand Down Expand Up @@ -4682,6 +4806,7 @@ export default function GatewayApp() {
activeView={activeView}
showProjects={isAgentMode && status?.online === true}
projects={workspaceProjects}
workspaceProjectGroups={settings.system.workspaceProjectGroups}
activeProjectId={activeWorkspaceProject?.id}
missingProjectPathKeys={missingWorkspaceProjectPathKeys}
projectRenamingId={projectRenamingId}
Expand All @@ -4697,6 +4822,11 @@ export default function GatewayApp() {
onProjectsCollapsedChange={handleSidebarProjectsCollapsedChange}
onRecentCollapsedChange={handleSidebarRecentCollapsedChange}
onCreateProject={handleOpenCreateWorkspaceProject}
onCreateWorkspaceGroup={handleCreateWorkspaceGroup}
onRenameWorkspaceGroup={handleRenameWorkspaceGroup}
onDeleteWorkspaceGroup={handleDeleteWorkspaceGroup}
onMoveProjectToGroup={handleMoveWorkspaceProjectToGroup}
onToggleWorkspaceGroupCollapsed={handleToggleWorkspaceGroupCollapsed}
onSelectProject={handleSelectWorkspaceProject}
onNewConversationForProject={handleNewConversationForProject}
onBrowseProjectInFileTree={handleBrowseWorkspaceProjectInFileTree}
Expand Down Expand Up @@ -5009,6 +5139,7 @@ export default function GatewayApp() {
reasoningOptions={chatRuntimeReasoningOptions}
thinkingAlwaysOn={chatRuntimeThinkingAlwaysOn}
gitClient={gitClient}
onOpenWorktree={handleOpenWorktree}
gitWriteEnabled={settings.remote.enableWebGit}
gitDisabledMessage={gitDisabledMessage}
workspaceActivityClient={workspaceActivityClient}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { SidebarSnapshot, SidebarStore } from "@liveagent/ui/lib/sidebar/st
import type { SidebarErrorCode } from "@liveagent/ui/lib/sidebar/types";
import { useSidebarSelector } from "@liveagent/ui/lib/sidebar/useSidebarSelector";
import { sortWorkspaceProjectsByActivity } from "@liveagent/ui/lib/workspaceProjects";
import type { WorkspaceProjectGroup } from "@liveagent/ui/lib/workspaceProjectTypes";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ChatHistorySummary } from "@/lib/chat/chatHistory";
import type { WorkspaceProject } from "@/lib/settings";
Expand Down Expand Up @@ -82,6 +83,7 @@ export type GatewaySidebarContainerProps = {
// the store's activity snapshot so project reordering never re-renders
// GatewayApp.
projects: WorkspaceProject[];
workspaceProjectGroups?: WorkspaceProjectGroup[];
activeProjectId?: string;
missingProjectPathKeys: ReadonlySet<string>;
projectRenamingId: string | null;
Expand All @@ -103,6 +105,11 @@ export type GatewaySidebarContainerProps = {
onProjectsCollapsedChange: (collapsed: boolean) => void;
onRecentCollapsedChange: (collapsed: boolean) => void;
onCreateProject: () => void;
onCreateWorkspaceGroup?: (name: string) => void;
onRenameWorkspaceGroup?: (groupId: string, name: string) => void;
onDeleteWorkspaceGroup?: (groupId: string) => void;
onMoveProjectToGroup?: (projectPath: string, groupId: string | null) => void;
onToggleWorkspaceGroupCollapsed?: (groupId: string) => void;
onSelectProject: (project: WorkspaceProject) => void;
onNewConversationForProject: (project: WorkspaceProject) => void;
onBrowseProjectInFileTree: (project: WorkspaceProject) => void;
Expand Down Expand Up @@ -362,6 +369,7 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) {
activeView={props.activeView}
showProjects={props.showProjects}
projects={sortedProjects}
workspaceProjectGroups={props.workspaceProjectGroups}
activeProjectId={props.activeProjectId}
missingProjectPathKeys={props.missingProjectPathKeys}
runningProjectPathKeys={projectActivityInputs.runningWorkdirPathKeys}
Expand All @@ -372,6 +380,11 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) {
onProjectsCollapsedChange={props.onProjectsCollapsedChange}
onRecentCollapsedChange={props.onRecentCollapsedChange}
onCreateProject={props.onCreateProject}
onCreateWorkspaceGroup={props.onCreateWorkspaceGroup}
onRenameWorkspaceGroup={props.onRenameWorkspaceGroup}
onDeleteWorkspaceGroup={props.onDeleteWorkspaceGroup}
onMoveProjectToGroup={props.onMoveProjectToGroup}
onToggleWorkspaceGroupCollapsed={props.onToggleWorkspaceGroupCollapsed}
onSelectProject={props.onSelectProject}
onNewConversationForProject={props.onNewConversationForProject}
onBrowseProjectInFileTree={props.onBrowseProjectInFileTree}
Expand Down
Loading
Loading