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/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 @@ -179,9 +179,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 @@ -1469,6 +1473,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 @@ -4912,6 +5036,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 @@ -4927,6 +5052,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 @@ -5241,6 +5371,7 @@ export default function GatewayApp() {
onManualCompactConfirm={handleManualCompact}
manualCompactBlocked={manualCompactPending || composerCompactionBlocked}
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 @@ -20,6 +20,7 @@ import { mergeTransientSidebarRunningActivity } from "@liveagent/ui/lib/sidebar/
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 @@ -86,6 +87,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 @@ -107,6 +109,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 @@ -383,6 +390,7 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) {
activeView={props.activeView}
showProjects={props.showProjects}
projects={sortedProjects}
workspaceProjectGroups={props.workspaceProjectGroups}
activeProjectId={props.activeProjectId}
missingProjectPathKeys={props.missingProjectPathKeys}
runningProjectPathKeys={effectiveRunningActivity.runningProjectPathKeys}
Expand All @@ -393,6 +401,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