diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts index 4ea67767..eb5547b1 100644 --- a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts @@ -81,6 +81,7 @@ export interface InstallRuntimeRequest { export interface LaunchRequest { "agent_id": string; + "working_directory": string; } export interface LaunchResponse { diff --git a/frontend/src/backend/wails.test.ts b/frontend/src/backend/wails.test.ts index ec3be91c..e9f91306 100644 --- a/frontend/src/backend/wails.test.ts +++ b/frontend/src/backend/wails.test.ts @@ -148,7 +148,7 @@ describe("Wails backend adapter", () => { expect(bridge.install).toHaveBeenCalledWith(expect.objectContaining({ agents: ["codex"], timeout: 0, agent_version: "" })); expect(bridge.register).toHaveBeenCalledWith({ provider: "ppio", agents: null }); expect(bridge.activate).toHaveBeenCalledWith(expect.objectContaining({ agent_id: "codex", profile_id: "", small_fast_model: "" })); - expect(bridge.launch).toHaveBeenCalledWith({ agent_id: "codex" }); + expect(bridge.launch).toHaveBeenCalledWith({ agent_id: "codex", working_directory: "" }); expect(bridge.desktopStatus).toHaveBeenCalledWith({ agent_id: "chatgpt-desktop" }); expect(bridge.desktopInstall).toHaveBeenCalledWith({ agent_id: "chatgpt-desktop" }); expect(bridge.desktopOpen).toHaveBeenCalledWith({ agent_id: "chatgpt-desktop" }); diff --git a/frontend/src/backend/wails.ts b/frontend/src/backend/wails.ts index 9df6fe52..4719385b 100644 --- a/frontend/src/backend/wails.ts +++ b/frontend/src/backend/wails.ts @@ -166,8 +166,8 @@ export const wailsApi = { profile_id: input.profileId ?? "", small_fast_model: input.smallFastModel ?? "", })) as Promise, - launchAgent: (agentId: string): Promise => - call(() => AgentService.Launch({ agent_id: agentId })) as Promise, + launchAgent: (agentId: string, workingDirectory = ""): Promise => + call(() => AgentService.Launch({ agent_id: agentId, working_directory: workingDirectory })) as Promise, updateAgent: (agentId: string): CancellableRequest => call(() => AgentService.Update({ agent_id: agentId })) as CancellableRequest, listRuntimes: (): Promise => diff --git a/frontend/src/components/AgentManageRow.test.tsx b/frontend/src/components/AgentManageRow.test.tsx index 2e8a5b82..b5c51935 100644 --- a/frontend/src/components/AgentManageRow.test.tsx +++ b/frontend/src/components/AgentManageRow.test.tsx @@ -12,7 +12,7 @@ const launchAgent = vi.fn(); vi.mock("../backend/api", async () => { const errors = await import("../backend/errors"); return { - api: { launchAgent: (agentId: string) => launchAgent(agentId) }, + api: { launchAgent: (agentId: string, directory: string) => launchAgent(agentId, directory) }, describeError: errors.describeError, }; }); @@ -71,6 +71,7 @@ function renderRow(over: Partial = {}, profileName = "团队 PPIO", ppio: { name: "PPIO", home: "https://ppio.com/", base_url: "https://api.ppio.com/openai" }, }} profileName={profileName} + defaultDirectory="/tmp" /> , ); @@ -202,7 +203,8 @@ describe("AgentManageRow", () => { it("launches the Agent it belongs to", async () => { renderRow(); await userEvent.click(screen.getByRole("button", { name: /启动/ })); - await waitFor(() => expect(launchAgent).toHaveBeenCalledWith("codex")); + await userEvent.click(screen.getByRole("dialog").querySelector("button[type=submit]") as HTMLElement); + await waitFor(() => expect(launchAgent).toHaveBeenCalledWith("codex", expect.any(String))); }); it("offers no launch for an Agent that is not installed", () => { @@ -214,6 +216,7 @@ describe("AgentManageRow", () => { launchAgent.mockRejectedValue(new OneAgentApiError("没有可用的终端", "PREREQUISITE_MISSING", false, 500)); renderRow(); await userEvent.click(screen.getByRole("button", { name: /启动/ })); + await userEvent.click(screen.getByRole("dialog").querySelector("button[type=submit]") as HTMLElement); expect(await screen.findByText("没有可用的终端")).toBeTruthy(); }); }); diff --git a/frontend/src/components/AgentManageRow.tsx b/frontend/src/components/AgentManageRow.tsx index eee487d2..4a9acfcb 100644 --- a/frontend/src/components/AgentManageRow.tsx +++ b/frontend/src/components/AgentManageRow.tsx @@ -1,4 +1,5 @@ -import { Play, RefreshCw, SlidersHorizontal } from "lucide-react"; +import { Dialogs } from "@wailsio/runtime"; +import { FolderOpen, Play, RefreshCw, SlidersHorizontal } from "lucide-react"; import { useState } from "react"; import { Link } from "react-router-dom"; @@ -116,6 +117,7 @@ export function AgentManageRow({ profileName, profile, onChanged, + defaultDirectory, }: { agentId: string; catalog: AgentCatalogItem | undefined; @@ -124,6 +126,7 @@ export function AgentManageRow({ profileName: string; profile?: ProfileSummary; onChanged?: () => void | Promise; + defaultDirectory?: string; }) { const { t } = useI18n(); const { startTask, finishTask, setTaskCanceller, taskFor, isTaskRunning } = useTaskCenter(); @@ -131,6 +134,9 @@ export function AgentManageRow({ const [launching, setLaunching] = useState(false); const [localUpdating, setLocalUpdating] = useState(false); const [failure, setFailure] = useState(""); + const [launchDirectory, setLaunchDirectory] = useState(""); + const [rememberDirectory, setRememberDirectory] = useState(false); + const [directoryDialog, setDirectoryDialog] = useState(false); const updateTaskID = taskKey("update", agentId); const updateTask = taskFor(updateTaskID); const updating = updateTask?.state === "running" || localUpdating; @@ -152,11 +158,28 @@ export function AgentManageRow({ const canLaunch = status.installed; const offer = updateOffer(catalog, status); - const launch = async () => { + const launch = () => { + const stored = localStorage.getItem(`oneagent:launch-directory:${agentId}`); + setLaunchDirectory(stored || defaultDirectory || ""); + setRememberDirectory(Boolean(stored)); + setDirectoryDialog(true); + }; + const chooseDirectory = async () => { + try { + const selected = await Dialogs.OpenFile({ Title: t("选择启动目录"), Directory: launchDirectory || defaultDirectory || undefined, CanChooseDirectories: true, CanChooseFiles: false }) as unknown as string | string[]; + const directory = Array.isArray(selected) ? selected[0] : selected; + if (directory) setLaunchDirectory(directory); + } catch { /* cancelled */ } + }; + const confirmLaunch = async () => { + if (!launchDirectory.trim()) return; + if (rememberDirectory) localStorage.setItem(`oneagent:launch-directory:${agentId}`, launchDirectory.trim()); + else localStorage.removeItem(`oneagent:launch-directory:${agentId}`); + setDirectoryDialog(false); setLaunching(true); setFailure(""); try { - await api.launchAgent(agentId); + await api.launchAgent(agentId, launchDirectory.trim()); } catch (error) { setFailure(describeError(error, t("无法启动 Agent")).message); } finally { @@ -223,6 +246,20 @@ export function AgentManageRow({ {statusLabel ? {statusLabel} : null} + {directoryDialog ? ( + +
{ event.preventDefault(); void confirmLaunch(); }}> +

{t("选择启动目录")}

+ +
+ setLaunchDirectory(event.target.value)} autoFocus /> + +
+ +
+
+
+ ) : null}
{/* Always in the row, not only when the Agent cannot launch. Configuring an installed Agent was previously reachable only by opening
, diff --git a/frontend/src/components/TaskCenter.test.tsx b/frontend/src/components/TaskCenter.test.tsx index 53fbfcb6..0b5828a1 100644 --- a/frontend/src/components/TaskCenter.test.tsx +++ b/frontend/src/components/TaskCenter.test.tsx @@ -51,6 +51,7 @@ function TaskHarness() { + -
+ )} + > + {failure ?
{failure}
: null} + {applied ?
{applied}
: null} {draft ? (
void save(event)}> diff --git a/frontend/src/pages/EnvironmentOverviewPage.tsx b/frontend/src/pages/EnvironmentOverviewPage.tsx index 0b1a5135..7b0fdecd 100644 --- a/frontend/src/pages/EnvironmentOverviewPage.tsx +++ b/frontend/src/pages/EnvironmentOverviewPage.tsx @@ -111,6 +111,7 @@ export function EnvironmentOverviewPage() { providers={status.providers} profileName={profile?.label || profile?.id || ""} profile={profile} + defaultDirectory={status.paths.launch_directory} onChanged={refreshStatus} />; })} diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 42306b2c..b4fb2573 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -219,6 +219,15 @@ .transfer-password-dialog form { display: grid; gap: 16px; } .transfer-password-dialog h2 { margin: 0; font-size: 15px; } .transfer-password-dialog footer { display: flex; justify-content: flex-end; gap: 8px; } +.transfer-password-dialog .launch-directory-label { margin-bottom: -10px; font-size: 13px; font-weight: 600; } +.launch-directory-input-row { display: flex; align-items: center; gap: 6px; } +.launch-directory-input-row input { min-width: 0; flex: 1; } +.launch-directory-input-row .icon-button { flex: 0 0 34px; width: 34px; height: 34px; border: 1px solid var(--border-strong); background: var(--surface-subtle); } +.launch-remember-row { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; } +.launch-remember-row input[type="checkbox"] { appearance: none; width: 17px; height: 17px; margin: 0; border: 1px solid var(--border-strong); border-radius: 4px; background: var(--window-bg); display: grid; place-content: center; cursor: pointer; } +.launch-remember-row input[type="checkbox"]::before { content: ""; width: 9px; height: 5px; border-left: 2px solid white; border-bottom: 2px solid white; transform: rotate(-45deg) scale(0); transition: transform 100ms ease-in-out; } +.launch-remember-row input[type="checkbox"]:checked { border-color: var(--blue); background: var(--blue); } +.launch-remember-row input[type="checkbox"]:checked::before { transform: rotate(-45deg) scale(1); } .settings-page { padding-top: 24px; display: grid; align-content: start; gap: 28px; } .settings-section { width: 100%; max-width: none; } diff --git a/internal/app/launch.go b/internal/app/launch.go index 47b9c06d..624f0b94 100644 --- a/internal/app/launch.go +++ b/internal/app/launch.go @@ -2,6 +2,8 @@ package app import ( "context" + "fmt" + "os" "strings" "github.com/MaimoryLab/OneAgent/internal/catalog" @@ -20,7 +22,7 @@ type LaunchAgentResult struct { // LaunchAgent opens a terminal window running one configured Agent. It reuses // nextStep, so the window gets the same command the activation screen tells the // user to run, including Aider's env file when that is what the Agent needs. -func (u *UseCases) LaunchAgent(ctx context.Context, agentID string) (LaunchAgentResult, error) { +func (u *UseCases) LaunchAgent(ctx context.Context, agentID string, directories ...string) (LaunchAgentResult, error) { if u == nil { return LaunchAgentResult{}, oneerrors.New(oneerrors.InternalError, "Agent service is not configured", oneerrors.WithStatus(501)) } @@ -51,6 +53,17 @@ func (u *UseCases) LaunchAgent(ctx context.Context, agentID string) (LaunchAgent // still the right thing to run. line = agent.Command } + workingDirectory := "" + if len(directories) > 0 { + workingDirectory = directories[0] + } + if workingDirectory != "" { + info, err := os.Stat(workingDirectory) + if err != nil || !info.IsDir() { + return LaunchAgentResult{}, oneerrors.New(oneerrors.InvalidRequest, fmt.Sprintf("Invalid launch directory: %s", workingDirectory)) + } + line = launchInDirectory(u.status.Platform.OS, workingDirectory, line) + } launcher, ok := process.AsLauncher(u.runner) if !ok { return LaunchAgentResult{}, oneerrors.New(oneerrors.InternalError, "This build cannot open a terminal window", oneerrors.WithStatus(501)) @@ -73,6 +86,17 @@ func (u *UseCases) LaunchAgent(ctx context.Context, agentID string) (LaunchAgent return LaunchAgentResult{Agent: agentID, Command: line}, nil } +func launchInDirectory(osID, directory, line string) string { + if osID == "windows" { + return `cd /d "` + strings.ReplaceAll(directory, `"`, `\"`) + `" && ` + line + } + return "cd " + shellQuote(directory) + " && " + line +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\\\"'\\\"'") + "'" +} + // linuxTerminals lists the emulators to try and how each takes a command. The // order prefers the desktop's own default before named emulators. var linuxTerminals = []struct { diff --git a/internal/app/launch_test.go b/internal/app/launch_test.go index 8efe5a39..322e2ebb 100644 --- a/internal/app/launch_test.go +++ b/internal/app/launch_test.go @@ -94,6 +94,18 @@ func TestLaunchAgentUsesCmdOnWindows(t *testing.T) { } } +func TestLaunchAgentChangesWorkingDirectory(t *testing.T) { + directory := t.TempDir() + core := launchCore(t, "linux", &launchRunner{paths: map[string]string{"x-terminal-emulator": "/usr/bin/x-terminal-emulator"}}) + result, err := core.LaunchAgent(context.Background(), "codex", directory) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(result.Command, "cd '") || !strings.Contains(result.Command, directory) { + t.Fatalf("launch command = %q", result.Command) + } +} + func TestOfficialInstallerUsesPowerShellOnWindows(t *testing.T) { runner := &launchRunner{} core := launchCore(t, "windows", runner) diff --git a/internal/app/status.go b/internal/app/status.go index a34bc9d9..ad90354e 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -258,7 +258,8 @@ func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) { } options := u.status paths := map[string]string{ - "profile": filepath.Join(options.Home, ".oneagent", "profile.json"), + "launch_directory": currentDirectory(), + "profile": filepath.Join(options.Home, ".oneagent", "profile.json"), // The Task Center points users at this directory when a command fails. // It has to come from here rather than being spelled out in the UI: a // hardcoded "~/.oneagent/logs" names a path that does not exist on @@ -375,6 +376,14 @@ func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) { }, nil } +func currentDirectory() string { + directory, err := os.Getwd() + if err != nil { + return "" + } + return filepath.Clean(directory) +} + func (u *UseCases) installedVersions(ctx context.Context, manifest catalog.Manifest, lookup func(string) (string, bool)) map[string]*string { queries := make([]struct { id, executable string diff --git a/internal/app/status_test.go b/internal/app/status_test.go index 0180577f..a58d414b 100644 --- a/internal/app/status_test.go +++ b/internal/app/status_test.go @@ -363,6 +363,11 @@ func TestStatusMatchesEmptyLinuxARM64Fixture(t *testing.T) { t.Fatal(err) } actual = normalizeFixtureHome(actual, home) + // The launch directory is the process working directory, which is not stable + // across test runners and is not part of this frozen environment fixture. + if paths, ok := actual.(map[string]any)["paths"].(map[string]any); ok { + delete(paths, "launch_directory") + } if !reflect.DeepEqual(actual, expected) { actualPretty, _ := json.MarshalIndent(actual, "", " ") expectedPretty, _ := json.MarshalIndent(expected, "", " ") diff --git a/internal/binding/services.go b/internal/binding/services.go index 2514f516..e65c073c 100644 --- a/internal/binding/services.go +++ b/internal/binding/services.go @@ -386,7 +386,7 @@ func (s *AgentService) Launch(ctx context.Context, request LaunchRequest) (Launc if s == nil || s.core == nil { return LaunchResponse{}, notReady("Agent launch is not configured") } - result, err := s.core.LaunchAgent(ctx, strings.TrimSpace(request.AgentID)) + result, err := s.core.LaunchAgent(ctx, strings.TrimSpace(request.AgentID), strings.TrimSpace(request.WorkingDirectory)) if err != nil { return LaunchResponse{}, err } @@ -576,7 +576,8 @@ type ActivateResponse struct { } type LaunchRequest struct { - AgentID string `json:"agent_id"` + AgentID string `json:"agent_id"` + WorkingDirectory string `json:"working_directory"` } type UpdateRequest struct {