chore: v2.8.12 — clone queue and progress, session defaults, chat agents, drops as @ mentions
Whole tree from parallel sessions, plus the version bump: - Clone: a queue with live progress (Add workspace and extra folders), clones run under nice/ionice so the machine stays responsive. - Settings → AI tools → Session defaults: a new session's default permission mode / bypass per account. - Chat lists the background subagents that have not reported back yet. - A file pasted or dropped into a claude pane goes in as an @ mention. - Changes panel: say why amend/undo is refused on a pushed commit. - openapi.yaml regenerated (Artifacts, Host, resumeById).
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @file SessionDefaultsSettings — Settings → AI tools → Session defaults: the
|
||||
* account's own starting choices for a new Claude Code session (permission
|
||||
* mode, Bypass included) and a new Codex session (sandbox, approval, bypass),
|
||||
* which the workspace home's prompt box starts from (`lib/sessionDefaults.ts`,
|
||||
* synced per account). Turning bypass on as a default asks first; a pick in
|
||||
* the prompt box still changes only that one session.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import type { CodexApproval, CodexSandbox } from "../../lib/api";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import {
|
||||
CODEX_APPROVALS,
|
||||
CODEX_SANDBOXES,
|
||||
DEFAULT_MODES,
|
||||
readCodexDefaults,
|
||||
readDefaultMode,
|
||||
writeCodexDefaults,
|
||||
writeDefaultMode,
|
||||
type DefaultMode,
|
||||
} from "../../lib/sessionDefaults";
|
||||
import { SettingsRow, SettingsSection, SettingsSegmented, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
const MODE_KEYS: Record<DefaultMode, string> = {
|
||||
auto: "Auto",
|
||||
acceptEdits: "AcceptEdits",
|
||||
default: "Default",
|
||||
plan: "Plan",
|
||||
bypassPermissions: "Bypass",
|
||||
};
|
||||
const SANDBOX_KEYS: Record<CodexSandbox, string> = {
|
||||
"read-only": "sandboxReadOnly",
|
||||
"workspace-write": "sandboxWorkspaceWrite",
|
||||
"danger-full-access": "sandboxFullAccess",
|
||||
};
|
||||
const APPROVAL_KEYS: Record<CodexApproval, string> = {
|
||||
"on-request": "approvalOnRequest",
|
||||
never: "approvalNever",
|
||||
};
|
||||
|
||||
function Danger({ children }: { children: string }) {
|
||||
return (
|
||||
<p className="flex items-start gap-2 py-2 text-xs text-status-danger">
|
||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionDefaultsSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const aiTools = useAiTools();
|
||||
const shown = (id: string) => aiTools.tools.some((x) => x.id === id && x.enabled);
|
||||
const [mode, setMode] = useState<DefaultMode>(() => readDefaultMode() as DefaultMode);
|
||||
const [codex, setCodex] = useState(readCodexDefaults);
|
||||
|
||||
const askBypass = (tool: string) =>
|
||||
confirmDialog({
|
||||
title:
|
||||
tool === "Codex"
|
||||
? t("sessionDefaults.codexConfirmTitle")
|
||||
: t("sessionDefaults.confirmTitle", { tool }),
|
||||
message: t("sessionDefaults.confirmBody", { tool }),
|
||||
confirmLabel: t("sessionDefaults.confirmLabel"),
|
||||
});
|
||||
|
||||
const pickMode = async (m: DefaultMode) => {
|
||||
if (m === "bypassPermissions" && !(await askBypass("Claude Code"))) return;
|
||||
writeDefaultMode(m);
|
||||
setMode(m);
|
||||
};
|
||||
const patchCodex = (patch: Partial<typeof codex>) => {
|
||||
writeCodexDefaults(patch);
|
||||
setCodex((c) => ({ ...c, ...patch }));
|
||||
};
|
||||
const pickCodexBypass = async (on: boolean) => {
|
||||
if (on && !(await askBypass("Codex"))) return;
|
||||
patchCodex({ bypass: on });
|
||||
};
|
||||
|
||||
if (!shown("claude") && !shown("codex")) return null;
|
||||
return (
|
||||
<SettingsSection
|
||||
id="session-defaults"
|
||||
title={t("sessionDefaults.title")}
|
||||
description={t("sessionDefaults.description")}
|
||||
>
|
||||
{shown("claude") && (
|
||||
<>
|
||||
<SettingsRow
|
||||
label={t("sessionDefaults.claudeMode")}
|
||||
description={t("sessionDefaults.claudeModeDesc")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("sessionDefaults.claudeMode")}
|
||||
value={mode}
|
||||
onChange={(m) => void pickMode(m)}
|
||||
options={DEFAULT_MODES.map((m) => ({
|
||||
value: m,
|
||||
label: t(`lanes:home.permission${MODE_KEYS[m]}`),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{mode === "bypassPermissions" && <Danger>{t("run:hint.permissionWarning")}</Danger>}
|
||||
</>
|
||||
)}
|
||||
{shown("codex") && (
|
||||
<>
|
||||
<SettingsRow
|
||||
label={t("sessionDefaults.codexSandbox")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("sessionDefaults.codexSandbox")}
|
||||
value={codex.sandbox}
|
||||
onChange={(sandbox) => patchCodex({ sandbox })}
|
||||
options={CODEX_SANDBOXES.map((v) => ({
|
||||
value: v,
|
||||
label: t(`run:fields.${SANDBOX_KEYS[v]}`),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
label={t("sessionDefaults.codexApproval")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("sessionDefaults.codexApproval")}
|
||||
value={codex.approval}
|
||||
onChange={(approval) => patchCodex({ approval })}
|
||||
options={CODEX_APPROVALS.map((v) => ({
|
||||
value: v,
|
||||
label: t(`run:fields.${APPROVAL_KEYS[v]}`),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsSwitchRow
|
||||
label={t("sessionDefaults.codexBypass")}
|
||||
description={t("sessionDefaults.codexBypassDesc")}
|
||||
checked={codex.bypass}
|
||||
onChange={(on) => void pickCodexBypass(on)}
|
||||
/>
|
||||
{codex.bypass && <Danger>{t("run:hint.bypassWarning", { tool: "Codex" })}</Danger>}
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @file SessionDefaultsSettings.test.tsx
|
||||
* @description Session defaults: Bypass becomes Claude's default permission
|
||||
* mode or Codex's default bypass only after the confirm, a cancelled confirm
|
||||
* keeps the old default, the plain modes save at once, and the pet chat's
|
||||
* safe read never returns bypass.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SessionDefaultsSettings } from "../SessionDefaultsSettings";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
import { readCodexDefaults, readDefaultMode } from "../../../lib/sessionDefaults";
|
||||
|
||||
const aiTools = vi.hoisted(() => ({
|
||||
tools: [
|
||||
{ id: "claude", label: "Claude Code", installed: true, enabled: true },
|
||||
{ id: "codex", label: "Codex", installed: true, enabled: true },
|
||||
],
|
||||
}));
|
||||
vi.mock("../../../hooks/useAiTools", () => ({ useAiTools: () => aiTools }));
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn() }));
|
||||
const confirm = vi.mocked(confirmDialog);
|
||||
|
||||
const radio = (name: RegExp) => screen.getAllByRole("radio", { name })[0]!;
|
||||
|
||||
describe("SessionDefaultsSettings", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
confirm.mockReset();
|
||||
});
|
||||
|
||||
it("saves a plain mode without asking", () => {
|
||||
render(<SessionDefaultsSettings />);
|
||||
fireEvent.click(radio(/plan/i));
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(readDefaultMode()).toBe("plan");
|
||||
});
|
||||
|
||||
it("makes Bypass Claude's default only once confirmed", async () => {
|
||||
confirm.mockResolvedValueOnce(false);
|
||||
render(<SessionDefaultsSettings />);
|
||||
fireEvent.click(radio(/bypass/i));
|
||||
await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1));
|
||||
expect(readDefaultMode()).toBe("auto");
|
||||
|
||||
confirm.mockResolvedValueOnce(true);
|
||||
fireEvent.click(radio(/bypass/i));
|
||||
await waitFor(() => expect(readDefaultMode()).toBe("bypassPermissions"));
|
||||
expect(readDefaultMode({ safe: true })).toBe("auto");
|
||||
});
|
||||
|
||||
it("turns Codex's default bypass on only once confirmed, off without asking", async () => {
|
||||
confirm.mockResolvedValueOnce(true);
|
||||
render(<SessionDefaultsSettings />);
|
||||
const sw = screen.getByRole("switch");
|
||||
fireEvent.click(sw);
|
||||
await waitFor(() => expect(readCodexDefaults().bypass).toBe(true));
|
||||
fireEvent.click(screen.getByRole("switch"));
|
||||
await waitFor(() => expect(readCodexDefaults().bypass).toBe(false));
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows nothing when neither Claude nor Codex is enabled", () => {
|
||||
const saved = aiTools.tools;
|
||||
aiTools.tools = [];
|
||||
const { container } = render(<SessionDefaultsSettings />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
aiTools.tools = saved;
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user