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
6 changes: 6 additions & 0 deletions src/main/sharedSettingsFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ describe("sharedSettingsFile", () => {
locale: "system",
gitTextLanguage: "en",
terminalPosition: "right",
windowsShellPath: "auto",
windowsInternalShellPath: "auto",
windowsShellArguments: "",
commitGenProvider: "auto",
commitGenModel: "",
commitGenEffort: "",
Expand Down Expand Up @@ -215,6 +218,9 @@ describe("sharedSettingsFile", () => {
locale: "system",
gitTextLanguage: "en",
terminalPosition: "right",
windowsShellPath: "auto",
windowsInternalShellPath: "auto",
windowsShellArguments: "",
commitGenProvider: "auto",
commitGenModel: "",
commitGenEffort: "",
Expand Down
26 changes: 25 additions & 1 deletion src/renderer/actions/agentLoginActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Project } from "@/shared/contracts";
import type { SupervisorEvent } from "@/shared/ipc";

const bridge = vi.hoisted(() => ({
startShell: vi.fn<() => Promise<void>>(),
startShell: vi.fn<(payload: unknown) => Promise<void>>(),
closeThread: vi.fn<() => Promise<void>>(),
onSupervisorEvent: vi.fn<(handler: (event: SupervisorEvent) => void) => () => void>(),
openExternal: vi.fn<(url: string) => Promise<void>>(),
Expand All @@ -18,6 +18,9 @@ const loginTerminalStore = vi.hoisted(() => ({
active: undefined as { onForceClose?: () => void; shellId: string } | undefined,
}));
const writeScriptToShellMock = vi.hoisted(() => vi.fn<(shellId: string, script: string) => void>());
const startShellWithCurrentSettingsMock = vi.hoisted(() =>
vi.fn<(payload: unknown) => Promise<void>>(),
);

vi.mock("@heroui/react", () => ({
toast: {
Expand Down Expand Up @@ -62,6 +65,8 @@ vi.mock("@/renderer/state/sharedSettingsStore", () => ({
}));

vi.mock("@/renderer/utils/shellUtils", () => ({
disposeRoutedShellSession: vi.fn<(shellId: string) => void>(),
startShellWithCurrentSettings: startShellWithCurrentSettingsMock,
writeScriptToShell: writeScriptToShellMock,
}));

Expand Down Expand Up @@ -133,6 +138,9 @@ describe("runAgentLoginCommand", () => {
loginTerminalStore.markFailed.mockReset();
loginTerminalStore.active = undefined;
writeScriptToShellMock.mockReset();
startShellWithCurrentSettingsMock
.mockReset()
.mockImplementation((payload) => bridge.startShell(payload));
});

it("opens hard-wrapped WSL auth URLs in the native browser", () => {
Expand Down Expand Up @@ -213,6 +221,13 @@ describe("runAgentLoginCommand", () => {
"Clear-Host; $env:CLAUDE_CONFIG_DIR = 'C:\\Users\\sdsle\\.poracode\\claude-profiles\\home'; claude auth login",
);
expect(script).not.toContain("CLAUDE_CONFIG_DIR=C:");
expect(startShellWithCurrentSettingsMock).toHaveBeenCalledWith(
expect.objectContaining({
projectLocation: windowsProject.location,
startInHome: true,
windowsShellRuntime: "powershell",
}),
);
});

it("sets profile env via an inline POSIX prefix on WSL", () => {
Expand Down Expand Up @@ -482,5 +497,14 @@ describe("runAgentLoginCommand", () => {
shellId: expect.stringMatching(/^update:/u),
}),
);
expect(startShellWithCurrentSettingsMock).toHaveBeenCalledWith(
expect.objectContaining({
projectLocation: posixProject.location,
startInHome: true,
}),
);
expect(startShellWithCurrentSettingsMock.mock.calls[0]?.[0]).not.toHaveProperty(
"windowsShellRuntime",
);
});
});
56 changes: 33 additions & 23 deletions src/renderer/actions/agentLoginActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import { useAppStore } from "@/renderer/state/appStore";
import { useDevTerminalStore } from "@/renderer/state/devTerminalStore";
import { useLoginTerminalStore } from "@/renderer/state/loginTerminalStore";
import { watchRoutedTerminal } from "@/renderer/state/remoteTerminalFeed";
import { disposeRoutedShellSession, writeScriptToShell } from "@/renderer/utils/shellUtils";
import {
disposeRoutedShellSession,
startShellWithCurrentSettings,
writeScriptToShell,
} from "@/renderer/utils/shellUtils";

function resolveLoginProject(): Project | undefined {
const app = useAppStore.getState();
Expand Down Expand Up @@ -129,20 +133,23 @@ export function runAgentLoginCommand(input: {
},
});

void readBridge()
void startShellWithCurrentSettings({
// Auth is global (writes to ~/.<agent>), so run login in the user's home
// directory rather than the (possibly ephemeral) project worktree.
.startShell({ shellId, projectLocation: project.location, startInHome: true })
.catch((error) => {
// The shell never started, so the completion watcher would otherwise leak
// (and leave callers' pending UI stuck). Tear it down and report failure.
stopWatching();
fireOnce(-1);
toast.danger(
error instanceof Error ? error.message : i18n._(msg`Unable to open ${input.label} login.`),
);
useLoginTerminalStore.getState().close();
});
shellId,
projectLocation: project.location,
startInHome: true,
...(project.location.kind === "windows" ? { windowsShellRuntime: "powershell" as const } : {}),
}).catch((error) => {
// The shell never started, so the completion watcher would otherwise leak
// (and leave callers' pending UI stuck). Tear it down and report failure.
stopWatching();
fireOnce(-1);
toast.danger(
error instanceof Error ? error.message : i18n._(msg`Unable to open ${input.label} login.`),
);
useLoginTerminalStore.getState().close();
});
writeScriptToShell(shellId, script, project.remoteServerId);
return true;
}
Expand Down Expand Up @@ -225,18 +232,21 @@ export function runAgentInstallCommand(input: {
},
});

void readBridge()
void startShellWithCurrentSettings({
// Installers shouldn't run inside the (possibly ephemeral) project
// worktree — launch the shell in the user's home directory instead.
.startShell({ shellId, projectLocation: project.location, startInHome: true })
.catch((error) => {
stopWatching();
fireOnce(-1);
toast.danger(
error instanceof Error ? error.message : i18n._(msg`Unable to install ${input.label}.`),
);
useLoginTerminalStore.getState().close();
});
shellId,
projectLocation: project.location,
startInHome: true,
...(project.location.kind === "windows" ? { windowsShellRuntime: "powershell" as const } : {}),
}).catch((error) => {
stopWatching();
fireOnce(-1);
toast.danger(
error instanceof Error ? error.message : i18n._(msg`Unable to install ${input.label}.`),
);
useLoginTerminalStore.getState().close();
});
writeScriptToShell(shellId, script, project.remoteServerId);
return true;
}
Expand Down
5 changes: 3 additions & 2 deletions src/renderer/actions/terminalActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe("runProjectAction", () => {
});
});

it("restarts an action in its existing tracked terminal", () => {
it("restarts an action in its existing tracked terminal", async () => {
runProjectAction(project.id, "dev");
const firstTab = useDevTerminalStore.getState().tabs[0]!;

Expand All @@ -65,7 +65,7 @@ describe("runProjectAction", () => {
id: firstTab.id,
runActionId: "dev",
});
expect(bridge.startShell).toHaveBeenCalledTimes(2);
await vi.waitFor(() => expect(bridge.startShell).toHaveBeenCalledTimes(2));
expect(bridge.startShell).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ shellId: firstTab.id }),
Expand Down Expand Up @@ -177,6 +177,7 @@ describe("runProjectAction", () => {
runProjectAction(project.id, "dev");
const tab = useDevTerminalStore.getState().tabs[0]!;
runProjectAction(project.id, "dev");
await vi.waitFor(() => expect(bridge.startShell).toHaveBeenCalledTimes(2));
resolveSecond();
rejectFirst(new Error("old start failed"));

Expand Down
6 changes: 3 additions & 3 deletions src/renderer/components/common/Select.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ describe("Select rich options", () => {
responsiveMenuState.mobile = false;
});

it("renders icon and detail in the desktop trigger and selects a rich option", async () => {
it("renders icon in the desktop trigger and selects a rich option with detail", async () => {
const onChange = vi.fn<(value: string) => void>();
render(<Select aria-label="Project" options={options} value="alpha" onChange={onChange} />);

const trigger = screen.getByLabelText("Project");
expect(trigger).toHaveTextContent("AlphaC:\\Alpha");
expect(trigger).toHaveTextContent("Alpha");
expect(trigger.querySelector('[data-testid="alpha-icon"]')).not.toBeNull();

fireEvent.click(trigger);
Expand All @@ -58,7 +58,7 @@ describe("Select rich options", () => {
render(<Select aria-label="Project" options={options} value="alpha" onChange={onChange} />);

const trigger = screen.getByRole("button", { name: "Project" });
expect(trigger).toHaveTextContent("AlphaC:\\Alpha");
expect(trigger).toHaveTextContent("Alpha");
fireEvent.click(trigger);

const beta = await screen.findByRole("button", { name: /Beta/u });
Expand Down
56 changes: 28 additions & 28 deletions src/renderer/components/common/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,6 @@ export function Select(props: SelectProps) {
<span className={`min-w-0 flex-1 truncate ${selectedOption ? "" : "text-muted"}`}>
{selectedOption?.label ?? placeholder}
</span>
{selectedOption?.detail ? (
<span className="min-w-0 shrink truncate text-xs text-muted/60">
{selectedOption.detail}
</span>
) : null}
<ChevronDown className="size-4 shrink-0 text-muted" />
</button>
}
Expand All @@ -96,12 +91,14 @@ export function Select(props: SelectProps) {
}}
>
{option.icon}
<span className="min-w-0 flex-1 truncate">{option.label}</span>
{option.detail ? (
<span className="max-w-28 shrink-0 truncate text-xs text-muted/60">
{option.detail}
</span>
) : null}
<div className="flex min-w-0 flex-1 flex-col items-start text-left">
<span className="min-w-0 truncate">{option.label}</span>
{option.detail ? (
<span className="min-w-0 truncate font-mono text-[11px] text-muted/70">
{option.detail}
</span>
) : null}
</div>
{selected ? <Check className="size-4 shrink-0 text-accent" /> : null}
</button>
);
Expand All @@ -118,17 +115,26 @@ export function Select(props: SelectProps) {
// rule but later in import order, collapsing the end padding that
// reserves room for the checkmark so it overlaps long labels. The
// `pe-7` utility (utilities layer) restores it.
<ListBox.Item key={option.id} id={option.id} textValue={option.label} className="pe-7">
<ListBox.Item
key={option.id}
id={option.id}
textValue={option.label}
className="relative pe-7"
>
{option.icon || option.detail ? (
<>
<div className="flex min-w-0 flex-1 items-center gap-2.5">
{option.icon}
<div className="flex min-w-0 flex-1 flex-col">
<Label className="truncate">{option.label}</Label>
<div className="flex min-w-0 flex-1 flex-col py-0.5">
<Label className="truncate text-sm font-medium text-foreground">
{option.label}
</Label>
{option.detail ? (
<Description className="truncate">{option.detail}</Description>
<Description className="truncate font-mono text-[11px] text-muted">
{option.detail}
</Description>
) : null}
</div>
</>
</div>
) : (
option.label
)}
Expand All @@ -147,21 +153,15 @@ export function Select(props: SelectProps) {
{label ? <Label>{label}</Label> : null}
<HeroSelect.Trigger>
<HeroSelect.Value>
{({ defaultChildren, isPlaceholder }) =>
!isPlaceholder && selectedOption?.icon ? (
{({ defaultChildren, isPlaceholder }) => {
if (isPlaceholder || !selectedOption) return defaultChildren;
return (
<span className="flex min-w-0 items-center gap-2">
{selectedOption.icon}
<span className="min-w-0 truncate">{selectedOption.label}</span>
{selectedOption.detail ? (
<span className="min-w-0 shrink truncate text-xs text-muted/60">
{selectedOption.detail}
</span>
) : null}
</span>
) : (
defaultChildren
)
}
);
}}
</HeroSelect.Value>
<HeroSelect.Indicator />
</HeroSelect.Trigger>
Expand Down
Loading