Skip to content
Merged
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
443 changes: 315 additions & 128 deletions crates/agent-gateway/internal/proto/v1/gateway.pb.go

Large diffs are not rendered by default.

62 changes: 62 additions & 0 deletions crates/agent-gateway/internal/server/websocket_fs_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,57 @@ func (c *websocketConnection) handleFsReadEditableText(req websocketRequest) {
_ = c.writeResponse(req.ID, websocketFsReadEditableTextResponsePayload(resp))
}

func (c *websocketConnection) handleFsReadWorkspaceImage(req websocketRequest) {
type payload struct {
Workdir string `json:"workdir"`
Path string `json:"path"`
}

var body payload
if err := decodeWebSocketPayload(req.Payload, &body); err != nil {
_ = c.writeError(req.ID, "invalid fs.read_workspace_image payload")
return
}

workdir := strings.TrimSpace(body.Workdir)
path := strings.TrimSpace(body.Path)
if workdir == "" {
_ = c.writeError(req.ID, "workdir is required")
return
}
if path == "" {
_ = c.writeError(req.ID, "path is required")
return
}

response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{
RequestId: req.ID,
Timestamp: time.Now().Unix(),
Payload: &gatewayv1.GatewayEnvelope_FsReadWorkspaceImage{
FsReadWorkspaceImage: &gatewayv1.FsReadWorkspaceImageRequest{
Workdir: workdir,
Path: path,
},
},
})
if err != nil {
_ = c.writeError(req.ID, websocketErrorMessage(err))
return
}
if errResp := response.GetError(); errResp != nil {
_ = c.writeError(req.ID, errResp.GetMessage())
return
}

resp := response.GetFsReadWorkspaceImageResp()
if resp == nil {
_ = c.writeError(req.ID, "unexpected agent response")
return
}

_ = c.writeResponse(req.ID, websocketFsReadWorkspaceImageResponsePayload(resp))
}

func (c *websocketConnection) handleFsWriteText(req websocketRequest) {
type payload struct {
Workdir string `json:"workdir"`
Expand Down Expand Up @@ -561,6 +612,17 @@ func websocketFsReadEditableTextResponsePayload(resp *gatewayv1.FsReadEditableTe
}
}

func websocketFsReadWorkspaceImageResponsePayload(resp *gatewayv1.FsReadWorkspaceImageResponse) map[string]any {
return map[string]any{
"path": resp.GetPath(),
"mimeType": resp.GetMimeType(),
"data": resp.GetData(),
"sizeBytes": resp.GetSizeBytes(),
"mtimeMs": resp.GetMtimeMs(),
"contentHash": resp.GetContentHash(),
}
}

func websocketFsWriteTextResponsePayload(resp *gatewayv1.FsWriteTextResponse) map[string]any {
return map[string]any{
"path": resp.GetPath(),
Expand Down
18 changes: 18 additions & 0 deletions crates/agent-gateway/internal/server/websocket_payload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,24 @@ func TestWebsocketFsPayloadsUseFrontendFieldNames(t *testing.T) {
t.Fatalf("fs.read_editable_text sizeBytes = %#v, want 11", readEditable["sizeBytes"])
}

readWorkspaceImage := websocketFsReadWorkspaceImageResponsePayload(&gatewayv1.FsReadWorkspaceImageResponse{
Path: "assets/preview.png",
MimeType: "image/png",
Data: "base64",
SizeBytes: 6,
MtimeMs: 42,
ContentHash: "hash",
})
if readWorkspaceImage["mimeType"] != "image/png" {
t.Fatalf("fs.read_workspace_image mimeType = %#v", readWorkspaceImage["mimeType"])
}
if readWorkspaceImage["sizeBytes"] != uint64(6) {
t.Fatalf("fs.read_workspace_image sizeBytes = %#v, want 6", readWorkspaceImage["sizeBytes"])
}
if readWorkspaceImage["contentHash"] != "hash" {
t.Fatalf("fs.read_workspace_image contentHash = %#v", readWorkspaceImage["contentHash"])
}

write := websocketFsWriteTextResponsePayload(&gatewayv1.FsWriteTextResponse{
Path: "src/new.ts",
Mode: "rewrite",
Expand Down
1 change: 1 addition & 0 deletions crates/agent-gateway/internal/server/websocket_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ var websocketRequestHandlers = map[string]websocketRequestHandler{
"fs.create_project_folder": (*websocketConnection).handleFsCreateProjectFolder,
"fs.list": (*websocketConnection).handleFsList,
"fs.read_editable_text": (*websocketConnection).handleFsReadEditableText,
"fs.read_workspace_image": (*websocketConnection).handleFsReadWorkspaceImage,
"fs.write_text": (*websocketConnection).handleFsWriteText,
"fs.create_dir": (*websocketConnection).handleFsCreateDir,
"fs.rename": (*websocketConnection).handleFsRename,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ func TestWebsocketRequestHandlersCoverKnownProtocolTypes(t *testing.T) {
"fs.create_project_folder",
"fs.list",
"fs.read_editable_text",
"fs.read_workspace_image",
"fs.write_text",
"fs.create_dir",
"fs.rename",
Expand Down
16 changes: 16 additions & 0 deletions crates/agent-gateway/proto/v1/gateway.proto
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ message GatewayEnvelope {
FsDeleteRequest fs_delete = 60;
GitRequest git_request = 61;
FsReadEditableTextRequest fs_read_editable_text = 62;
FsReadWorkspaceImageRequest fs_read_workspace_image = 63;
}
}

Expand Down Expand Up @@ -108,6 +109,7 @@ message AgentEnvelope {
FsDeleteResponse fs_delete_resp = 63;
GitResponse git_response = 64;
FsReadEditableTextResponse fs_read_editable_text_resp = 65;
FsReadWorkspaceImageResponse fs_read_workspace_image_resp = 66;
ErrorResponse error = 99;
}
}
Expand Down Expand Up @@ -565,6 +567,20 @@ message FsReadEditableTextResponse {
uint64 total_lines = 6;
}

message FsReadWorkspaceImageRequest {
string workdir = 1;
string path = 2;
}

message FsReadWorkspaceImageResponse {
string path = 1;
string mime_type = 2;
string data = 3;
uint64 size_bytes = 4;
uint64 mtime_ms = 5;
string content_hash = 6;
}

message FsWriteTextRequest {
string workdir = 1;
string path = 2;
Expand Down
83 changes: 69 additions & 14 deletions crates/agent-gateway/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ProjectToolsPanel } from "@/components/project-tools/ProjectToolsPanel";
import type { WorkspaceCodeEditorOpenRequest } from "@/components/workspace-editor/WorkspaceCodeEditorOverlay";
import type { WorkspaceImagePreviewOpenRequest } from "@/components/workspace-editor/WorkspaceImagePreviewOverlay";
import { isWorkspaceImagePath } from "@/components/workspace-editor/workspaceImagePreview";
import { LocaleContext, t as translate } from "@/i18n";
import type {
MentionComposerCommitMention,
Expand Down Expand Up @@ -216,6 +218,13 @@ const WorkspaceCodeEditorOverlay = lazy(async () => {
};
});

const WorkspaceImagePreviewOverlay = lazy(async () => {
const module = await import("@/components/workspace-editor/WorkspaceImagePreviewOverlay");
return {
default: module.WorkspaceImagePreviewOverlay,
};
});

const MAX_UPLOAD_FILES = 9;

function dragEventHasFiles(event: DragEvent<HTMLElement>) {
Expand Down Expand Up @@ -817,16 +826,19 @@ export default function App() {
const [isFileDropActive, setIsFileDropActive] = useState(false);
const [activeView, setActiveView] = useState<"chat" | "skills-hub" | "mcp-hub">("chat");
const [projectToolsPanelOpen, setProjectToolsPanelOpen] = useState(false);
const projectToolsFileTreeOpenCount =
settings.customSettings.projectToolsFileTree.openProjectPathKeys.length;
const previousProjectToolsFileTreeOpenCountRef = useRef(projectToolsFileTreeOpenCount);
const previousProjectToolsFileTreeOpenRef = useRef(false);
const [workspaceEditorMounted, setWorkspaceEditorMounted] = useState(false);
const [workspaceEditorOpen, setWorkspaceEditorOpen] = useState(false);
const [workspaceEditorCleanupPending, setWorkspaceEditorCleanupPending] = useState(false);
const [workspaceEditorOpenRequest, setWorkspaceEditorOpenRequest] =
useState<WorkspaceCodeEditorOpenRequest | null>(null);
const [workspaceEditorCloseRequestId, setWorkspaceEditorCloseRequestId] = useState(0);
const workspaceEditorRequestIdRef = useRef(0);
const [workspaceImagePreviewMounted, setWorkspaceImagePreviewMounted] = useState(false);
const [workspaceImagePreviewOpen, setWorkspaceImagePreviewOpen] = useState(false);
const [workspaceImagePreviewOpenRequest, setWorkspaceImagePreviewOpenRequest] =
useState<WorkspaceImagePreviewOpenRequest | null>(null);
const workspaceImagePreviewRequestIdRef = useRef(0);
const [terminalSessions, setTerminalSessions] = useState<TerminalSession[]>([]);
const { confirm: requestConfirmDialog, dialog: confirmDialog } = useConfirmDialog();
const terminalSessionsVersionRef = useRef(0);
Expand Down Expand Up @@ -5280,6 +5292,10 @@ export default function App() {
const terminalProjectPathKey = terminalProjectPath
? workspaceProjectPathKey(terminalProjectPath)
: "";
const projectToolsFileTreeOpen = isProjectToolsFileTreeOpen(
settings.customSettings,
terminalProjectPathKey,
);
const projectToolsDisabledMessage = !settingsSyncReady
? "Syncing desktop settings..."
: !isAgentMode
Expand All @@ -5295,9 +5311,22 @@ export default function App() {
const gitDisabledMessage = !settings.remote.enableWebGit
? "WebUI Git is disabled in desktop Remote settings."
: undefined;
const handleOpenEditableFile = useCallback(
const handleOpenWorkspaceFile = useCallback(
(path: string) => {
if (!terminalProjectPath || !terminalProjectPathKey) return;
if (isWorkspaceImagePath(path)) {
workspaceImagePreviewRequestIdRef.current += 1;
setWorkspaceImagePreviewMounted(true);
setWorkspaceImagePreviewOpen(true);
setWorkspaceImagePreviewOpenRequest({
id: workspaceImagePreviewRequestIdRef.current,
projectPathKey: terminalProjectPathKey,
workdir: terminalProjectPath,
path,
});
return;
}
setWorkspaceImagePreviewOpen(false);
workspaceEditorRequestIdRef.current += 1;
setWorkspaceEditorCleanupPending(false);
setWorkspaceEditorMounted(true);
Expand All @@ -5314,22 +5343,35 @@ export default function App() {
const requestWorkspaceEditorClose = useCallback(() => {
setWorkspaceEditorCloseRequestId((current) => current + 1);
}, []);
const requestWorkspaceImagePreviewClose = useCallback(() => {
setWorkspaceImagePreviewOpen(false);
}, []);
const handleWorkspaceImagePreviewClosed = useCallback(() => {
setWorkspaceImagePreviewOpen(false);
setWorkspaceImagePreviewMounted(false);
setWorkspaceImagePreviewOpenRequest(null);
}, []);
useEffect(() => {
const previousOpenCount = previousProjectToolsFileTreeOpenCountRef.current;
previousProjectToolsFileTreeOpenCountRef.current = projectToolsFileTreeOpenCount;
if (projectToolsFileTreeOpenCount > 0 && workspaceEditorCleanupPending) {
const previousOpen = previousProjectToolsFileTreeOpenRef.current;
previousProjectToolsFileTreeOpenRef.current = projectToolsFileTreeOpen;
if (projectToolsFileTreeOpen && workspaceEditorCleanupPending) {
setWorkspaceEditorCleanupPending(false);
}
if (previousOpenCount > 0 && projectToolsFileTreeOpenCount === 0 && workspaceEditorMounted) {
if (previousOpen && !projectToolsFileTreeOpen && workspaceEditorMounted) {
setWorkspaceEditorCleanupPending(true);
setWorkspaceEditorOpen(true);
requestWorkspaceEditorClose();
}
if (previousOpen && !projectToolsFileTreeOpen && workspaceImagePreviewMounted) {
requestWorkspaceImagePreviewClose();
}
}, [
projectToolsFileTreeOpenCount,
projectToolsFileTreeOpen,
requestWorkspaceEditorClose,
requestWorkspaceImagePreviewClose,
workspaceEditorCleanupPending,
workspaceEditorMounted,
workspaceImagePreviewMounted,
]);
const projectTerminalSessions = useMemo(
() =>
Expand Down Expand Up @@ -6341,6 +6383,22 @@ export default function App() {
/>
</Suspense>
) : null}
{workspaceImagePreviewMounted ? (
<Suspense
fallback={
<div className="workspace-image-preview-overlay absolute inset-0 z-40 flex items-center justify-center border-r border-border bg-background text-sm text-muted-foreground shadow-2xl">
{translate("workspaceImagePreview.loading", settings.locale)}
</div>
}
>
<WorkspaceImagePreviewOverlay
openRequest={workspaceImagePreviewOpenRequest}
isOpen={workspaceImagePreviewOpen}
onRequestClose={requestWorkspaceImagePreviewClose}
onClose={handleWorkspaceImagePreviewClosed}
/>
</Suspense>
) : null}
</div>

{terminalClient ? (
Expand All @@ -6356,10 +6414,7 @@ export default function App() {
terminalDisabledMessage={terminalDisabledMessage}
activeTab={settings.customSettings.projectToolsPanel.activeTab}
tabOrder={getProjectToolsPanelTabOrder(settings.customSettings, terminalProjectPathKey)}
fileTreeOpen={isProjectToolsFileTreeOpen(
settings.customSettings,
terminalProjectPathKey,
)}
fileTreeOpen={projectToolsFileTreeOpen}
fileTreeState={getProjectToolsFileTreeProjectState(
settings.customSettings,
terminalProjectPathKey,
Expand Down Expand Up @@ -6417,7 +6472,7 @@ export default function App() {
composerRef.current?.insertFileMention(path, kind);
composerRef.current?.focus();
}}
onOpenEditableFile={handleOpenEditableFile}
onOpenFile={handleOpenWorkspaceFile}
onInsertCommitMention={(commit) => {
composerRef.current?.insertCommitMention(commit);
composerRef.current?.focus();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
} from "@/lib/settings";
import { cn } from "@/lib/shared/utils";
import { getFileTypeIcon } from "../chat/fileTypeIcons";
import { isWorkspaceImagePath } from "../workspace-editor/workspaceImagePreview";
import {
Check,
ChevronRight,
Expand All @@ -15,6 +16,7 @@ import {
FilePenLine,
Folder,
FolderOpen,
ImageIcon,
Loader2,
Plus,
RefreshCw,
Expand Down Expand Up @@ -149,7 +151,7 @@ export function ProjectFileTreePanel(props: {
onInitializedChange: (initialized: boolean) => void;
onSyncStateChange: (patch: ProjectToolsFileTreeStatePatch) => void;
onInsertFileMention?: (path: string, kind: FileTreeKind) => void;
onOpenEditableFile?: (path: string) => void;
onOpenFile?: (path: string) => void;
}) {
const {
projectPathKey,
Expand All @@ -159,7 +161,7 @@ export function ProjectFileTreePanel(props: {
onInitializedChange,
onSyncStateChange,
onInsertFileMention,
onOpenEditableFile,
onOpenFile,
} = props;
const { t } = useLocale();
const [states, setStates] = useState<Record<string, FileTreeState>>({});
Expand Down Expand Up @@ -737,7 +739,7 @@ export function ProjectFileTreePanel(props: {
toggleDirectory(path, expanded);
return;
}
onOpenEditableFile?.(path);
onOpenFile?.(path);
}}
>
{node.kind === "dir" ? (
Expand All @@ -763,7 +765,7 @@ export function ProjectFileTreePanel(props: {
},
[
cwd,
onOpenEditableFile,
onOpenFile,
openContextMenu,
setProjectState,
state,
Expand Down Expand Up @@ -959,14 +961,22 @@ export function ProjectFileTreePanel(props: {
type="button"
role="menuitem"
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-45"
disabled={!onOpenEditableFile}
disabled={!onOpenFile}
onClick={() => {
onOpenEditableFile?.(contextPath);
onOpenFile?.(contextPath);
setContextMenu(null);
}}
>
<FilePenLine className="h-3.5 w-3.5" />
{t("projectTools.fileTree.openFile")}
{isWorkspaceImagePath(contextPath) ? (
<ImageIcon className="h-3.5 w-3.5" />
) : (
<FilePenLine className="h-3.5 w-3.5" />
)}
{t(
isWorkspaceImagePath(contextPath)
? "projectTools.fileTree.previewImage"
: "projectTools.fileTree.openFile",
)}
</button>
<div className="mx-1 my-1 h-px bg-border/60" />
</>
Expand Down
Loading
Loading