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
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export interface InstallRuntimeRequest {

export interface LaunchRequest {
"agent_id": string;
"working_directory": string;
}

export interface LaunchResponse {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/backend/wails.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/backend/wails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,8 @@ export const wailsApi = {
profile_id: input.profileId ?? "",
small_fast_model: input.smallFastModel ?? "",
})) as Promise<ActivateAgentResponse>,
launchAgent: (agentId: string): Promise<LaunchAgentResponse> =>
call(() => AgentService.Launch({ agent_id: agentId })) as Promise<LaunchAgentResponse>,
launchAgent: (agentId: string, workingDirectory = ""): Promise<LaunchAgentResponse> =>
call(() => AgentService.Launch({ agent_id: agentId, working_directory: workingDirectory })) as Promise<LaunchAgentResponse>,
updateAgent: (agentId: string): CancellableRequest<AgentUpdateResult> =>
call(() => AgentService.Update({ agent_id: agentId })) as CancellableRequest<AgentUpdateResult>,
listRuntimes: (): Promise<RuntimeStatus[]> =>
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/components/AgentManageRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
});
Expand Down Expand Up @@ -71,6 +71,7 @@ function renderRow(over: Partial<AgentStatus> = {}, profileName = "团队 PPIO",
ppio: { name: "PPIO", home: "https://ppio.com/", base_url: "https://api.ppio.com/openai" },
}}
profileName={profileName}
defaultDirectory="/tmp"
/>
</MemoryRouter>,
);
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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();
});
});
Expand Down
43 changes: 40 additions & 3 deletions frontend/src/components/AgentManageRow.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -116,6 +117,7 @@ export function AgentManageRow({
profileName,
profile,
onChanged,
defaultDirectory,
}: {
agentId: string;
catalog: AgentCatalogItem | undefined;
Expand All @@ -124,13 +126,17 @@ export function AgentManageRow({
profileName: string;
profile?: ProfileSummary;
onChanged?: () => void | Promise<void>;
defaultDirectory?: string;
}) {
const { t } = useI18n();
const { startTask, finishTask, setTaskCanceller, taskFor, isTaskRunning } = useTaskCenter();
const route = useTaskRoute();
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;
Expand All @@ -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 {
Expand Down Expand Up @@ -223,6 +246,20 @@ export function AgentManageRow({
{statusLabel ? <span className={`agent-manage-state${failure || updateFailure ? " is-error" : ""}`}>{statusLabel}</span> : null}
</div>
</div>
{directoryDialog ? (
<dialog className="transfer-password-dialog" open>
<form onSubmit={(event) => { event.preventDefault(); void confirmLaunch(); }}>
<h2>{t("选择启动目录")}</h2>
<label className="launch-directory-label" htmlFor={`launch-directory-${agentId}`}>{t("启动目录")}</label>
<div className="launch-directory-input-row">
<input id={`launch-directory-${agentId}`} value={launchDirectory} onChange={(event) => setLaunchDirectory(event.target.value)} autoFocus />
<button className="icon-button" type="button" onClick={() => void chooseDirectory()} title={t("选择目录")} aria-label={t("选择目录")}><FolderOpen size={18} /></button>
</div>
<label className="launch-remember-row"><input type="checkbox" checked={rememberDirectory} onChange={(event) => setRememberDirectory(event.target.checked)} /><span>{t("记住此 Agent 的目录")}</span></label>
<footer><button className="button button-secondary" type="button" onClick={() => setDirectoryDialog(false)}>{t("取消")}</button><button className="button button-primary" type="submit">{t("启动")}</button></footer>
</form>
</dialog>
) : null}
<div className="agent-manage-actions">
{/* Always in the row, not only when the Agent cannot launch. Configuring
an installed Agent was previously reachable only by opening <details>,
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/components/TaskCenter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ function TaskHarness() {
<button type="button" onClick={() => finishTask(installTask.id!, { kind: "success", message: "安装完成" })}>完成安装</button>
<button type="button" onClick={() => setTaskAction(installTask.id!, { label: "重试安装", run: terminalAction })}>设置终端动作</button>
<button type="button" onClick={() => { startTask(installTask); }}>再次启动</button>
<button type="button">外部区域</button>
<button type="button" onClick={() => { startTask({ kind: "update", target: "codex", title: "更新 Codex", route: "/overview" }); }}>更新同一 Agent</button>
<button type="button" onClick={() => {
for (const target of ["node", "uv"]) {
Expand Down Expand Up @@ -104,6 +105,15 @@ describe("TaskCenter", () => {
expect(screen.queryByText(/暂无任务日志|清空|完整日志/)).toBeNull();
});

it("closes when the user clicks outside the task center", async () => {
const user = userEvent.setup();
renderTaskCenter();
await user.click(screen.getByRole("button", { name: "任务中心" }));
expect(screen.getByText("暂无任务")).toBeTruthy();
await user.click(screen.getByRole("button", { name: "外部区域" }));
expect(screen.queryByText("暂无任务")).toBeNull();
});

it("returns to the route recorded by a card after navigation", async () => {
const user = userEvent.setup();
renderTaskCenter();
Expand All @@ -117,6 +127,7 @@ describe("TaskCenter", () => {
renderTaskCenter();
await user.click(screen.getByRole("button", { name: "启动安装" }));
await user.click(screen.getByRole("button", { name: "完成安装" }));
await user.click(screen.getByRole("button", { name: "任务中心" }));
expect(await screen.findByText(/已完成/)).toBeTruthy();
await user.click(screen.getByRole("button", { name: "关闭任务" }));
expect(screen.queryByText("安装 Codex")).toBeNull();
Expand All @@ -129,6 +140,7 @@ describe("TaskCenter", () => {
await user.click(screen.getByRole("button", { name: "设置终端动作" }));
expect(screen.queryByRole("button", { name: "重试安装" })).toBeNull();
await user.click(screen.getByRole("button", { name: "完成安装" }));
await user.click(screen.getByRole("button", { name: "任务中心" }));
await user.click(screen.getByRole("button", { name: "重试安装" }));
expect(terminalAction).toHaveBeenCalledTimes(1);
});
Expand All @@ -151,6 +163,7 @@ describe("TaskCenter", () => {
await user.click(screen.getByRole("button", { name: "启动安装" }));
await user.click(screen.getByRole("button", { name: "再次启动" }));
await user.click(screen.getByRole("button", { name: "更新同一 Agent" }));
await user.click(screen.getByRole("button", { name: "任务中心" }));
expect(screen.getAllByText("安装 Codex")).toHaveLength(1);
expect(screen.queryByText("更新 Codex")).toBeNull();
});
Expand Down
14 changes: 12 additions & 2 deletions frontend/src/components/TaskCenter.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CheckCircle2, ChevronDown, CircleAlert, CircleStop, LoaderCircle, ListChecks, RefreshCw, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useInRouterContext, useNavigate } from "react-router-dom";

import { useI18n } from "../i18n";
Expand Down Expand Up @@ -84,14 +84,24 @@ function TaskCenterShell({ navigate }: { navigate: (route: string) => void }) {
const { t } = useI18n();
const { tasks } = useTaskCenter();
const [open, setOpen] = useState(false);
const centerRef = useRef<HTMLElement>(null);
const active = tasks.some((task) => task.state === "running");

useEffect(() => {
if (active) setOpen(true);
}, [active]);

useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
if (!centerRef.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open]);

return (
<section className={`task-center${open ? " is-open" : ""}`}>
<section ref={centerRef} className={`task-center${open ? " is-open" : ""}`}>
<button
type="button"
className="task-center-trigger"
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,10 @@ const english = {
"启动": "Launch",
"在新终端窗口中启动,并载入 OneAgent 写入的配置": "Open a new terminal window running this agent with the configuration OneAgent wrote",
"无法启动 Agent": "Could not launch the agent",
"选择启动目录": "Choose launch directory",
"启动目录": "Launch directory",
"选择目录": "Choose directory",
"记住此 Agent 的目录": "Remember this directory for this agent",
"更新": "Update",
"更新中": "Updating",
"更新完成": "Update complete",
Expand Down
11 changes: 5 additions & 6 deletions frontend/src/pages/AgentProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,15 +173,14 @@ export function AgentProfilePage() {
onPrimary={() => void apply()}
primaryDisabled={!canApply || busy}
footerNote={selected?.label || t("选择一个 Profile")}
>
{failure ? <div className="notice notice-error">{failure}</div> : null}
{applied ? <div className="notice notice-success">{applied}</div> : null}

<div className="profile-toolbar">
secondaryAction={(
<button className="button button-secondary" type="button" onClick={openCreate} disabled={Boolean(draft)}>
<Plus size={15} />{t("创建 Profile")}
</button>
</div>
)}
>
{failure ? <div className="notice notice-error">{failure}</div> : null}
{applied ? <div className="notice notice-success">{applied}</div> : null}

{draft ? (
<form className="profile-editor desktop-profile-editor" onSubmit={(event) => void save(event)}>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/pages/EnvironmentOverviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export function EnvironmentOverviewPage() {
providers={status.providers}
profileName={profile?.label || profile?.id || ""}
profile={profile}
defaultDirectory={status.paths.launch_directory}
onChanged={refreshStatus}
/>;
})}
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
26 changes: 25 additions & 1 deletion internal/app/launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package app

import (
"context"
"fmt"
"os"
"strings"

"github.com/MaimoryLab/OneAgent/internal/catalog"
Expand All @@ -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))
}
Expand Down Expand Up @@ -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))
Expand All @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions internal/app/launch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 10 additions & 1 deletion internal/app/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions internal/app/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "", " ")
Expand Down
Loading
Loading