Add Code Queue workdir presets

This commit is contained in:
Codex
2026-05-16 16:07:39 +00:00
parent 4f13ea92c7
commit d77a0e129a
5 changed files with 391 additions and 57 deletions
File diff suppressed because one or more lines are too long
+22
View File
@@ -2483,6 +2483,25 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
.codex-submit-queue-select {
grid-area: queue-select;
}
.codex-workdir-field {
grid-column: 1 / -1;
}
.codex-workdir-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(120px, 0.72fr) auto auto;
gap: 6px;
align-items: center;
min-width: 0;
}
.codex-workdir-row input,
.codex-workdir-row select {
min-width: 0;
}
.codex-workdir-create-btn,
.codex-workdir-delete-btn {
min-height: 32px;
white-space: nowrap;
}
.codex-rename-queue-btn,
.codex-merge-queue-btn,
.codex-create-queue-btn {
@@ -5574,6 +5593,9 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
"merge"
"create";
}
.codex-workdir-row {
grid-template-columns: 1fr;
}
.codex-rename-queue-btn, .codex-merge-queue-btn, .codex-create-queue-btn { width: 100%; }
.codex-session-title-toggle { min-height: 40px; padding: 9px 15px; font-size: 14px; }
.codex-attempt-cycle-head { align-items: flex-start; }
+109 -1
View File
@@ -1367,6 +1367,45 @@ function providerDefaultWorkdir(queue: any, providerId: string): string {
return id === mainProvider ? String(queue?.defaultWorkdir || "/workspace") : String(queue?.remoteDefaultWorkdir || "/home/ubuntu");
}
function normalizedWorkdirPath(value: any): string {
return String(value || "").trim().replace(/\/+$/u, "") || "/";
}
function workdirRecordMatches(record: any, providerId: string, executionMode: string): boolean {
return String(record?.providerId || "") === String(providerId || "")
&& String(record?.executionMode || "default") === String(executionMode || "default")
&& String(record?.path || "").trim().length > 0;
}
function workdirOptions(queue: any, records: any[], providerId: string, executionMode: string, cwd: string): any[] {
const byPath = new Map<string, any>();
const currentPath = normalizedWorkdirPath(cwd);
const add = (pathValue: any, source: string, record: any = {}) => {
const path = normalizedWorkdirPath(pathValue);
if (path.length === 0 || byPath.has(path)) return;
byPath.set(path, {
providerId,
executionMode,
path,
source,
createdAt: record?.createdAt || "",
updatedAt: record?.updatedAt || "",
});
};
add(executionModeDefaultWorkdir(queue, executionMode, providerId), "default");
for (const record of Array.isArray(records) ? records : []) {
if (workdirRecordMatches(record, providerId, executionMode)) add(record.path, "saved", record);
}
add(cwd, "current");
return Array.from(byPath.values()).sort((left, right) => {
if (left.path === currentPath) return -1;
if (right.path === currentPath) return 1;
if (left.source === "default" && right.source !== "default") return -1;
if (right.source === "default" && left.source !== "default") return 1;
return left.path.localeCompare(right.path);
});
}
function taskStepCount(task: any): number | null {
return canonicalTaskStepCount(task);
}
@@ -2042,6 +2081,8 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
const [copiedTaskId, setCopiedTaskId] = useState("");
const [markingReadTaskId, setMarkingReadTaskId] = useState("");
const [markingAllRead, setMarkingAllRead] = useState(false);
const [workdirData, setWorkdirData] = useState(null);
const [workdirBusy, setWorkdirBusy] = useState(false);
const [loadStats, setLoadStats] = useState(initialTasksData ? {
phase: "complete",
taskId: initialSelectedId,
@@ -2104,6 +2145,9 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
const providerOptions = codexProviderOptions(queue, providerId);
const executionModeRows = codexExecutionModeOptions(queue, executionMode);
const currentProviderDefaultWorkdir = executionModeDefaultWorkdir(queue, executionMode, providerId);
const savedWorkdirs = Array.isArray(workdirData?.workdirs) ? workdirData.workdirs : [];
const currentWorkdirOptions = workdirOptions(queue, savedWorkdirs, providerId, executionMode, cwd);
const currentWorkdirSaved = currentWorkdirOptions.some((item: any) => item.source === "saved" && normalizedWorkdirPath(item.path) === normalizedWorkdirPath(cwd));
const selectedCanSteer = selectedTask?.id && selectedTask?.activeTurnId && String(selectedTask?.status) === "running";
const selectedCanInterrupt = selectedTask?.id && !["succeeded", "failed", "canceled"].includes(String(selectedTask?.status || ""));
const selectedCanRetry = selectedTask?.id && ["succeeded", "failed", "canceled"].includes(String(selectedTask?.status || ""));
@@ -2175,6 +2219,8 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
const next = String(nextProviderId || queue?.mainProviderId || "D601").trim() || "D601";
setProviderId(next);
setCwd(executionModeDefaultWorkdir(queue, executionMode, next));
setWorkdirData(null);
void loadWorkdirs().catch((err) => setError(errorText(err, "加载工作目录失败")));
}
function changeSubmitExecutionMode(nextMode: string): void {
@@ -2189,6 +2235,8 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
}
setExecutionMode(next);
setCwd(executionModeDefaultWorkdir(queue, next, nextProvider));
setWorkdirData(null);
void loadWorkdirs().catch((err) => setError(errorText(err, "加载工作目录失败")));
}
function patchLoadedReadState(taskIds: string[], readAt: string, queuePatch: any = null, taskPatch: any = null): void {
@@ -2976,6 +3024,51 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
}
}
async function loadWorkdirs(): Promise<void> {
if (!service) return;
const result = await requestJson(codexApi(apiBaseUrl, "/api/workdirs"));
setWorkdirData(result);
}
async function createWorkdirPreset(): Promise<void> {
const defaultPath = cwd.trim() || currentProviderDefaultWorkdir || "/workspace";
const proposed = typeof window === "undefined" ? defaultPath : window.prompt("输入新的工作目录绝对路径", defaultPath);
const path = String(proposed || "").trim();
if (!path) return;
setWorkdirBusy(true);
await guarded(async () => {
const result = await requestJson(codexApi(apiBaseUrl, "/api/workdirs"), {
method: "POST",
body: { providerId, executionMode, path, ensure: true },
});
setWorkdirData((previous: any) => ({ ...(previous || {}), ...result }));
setCwd(String(result?.workdir?.path || path));
const msg = `已保存工作目录:${String(result?.workdir?.path || path)}`;
setNotice(msg);
addNotification("success", msg);
}, "创建工作目录失败");
setWorkdirBusy(false);
}
async function deleteWorkdirPreset(): Promise<void> {
const path = normalizedWorkdirPath(cwd);
if (!currentWorkdirSaved) {
setNotice("当前工作目录还没有保存到下拉菜单。");
return;
}
const confirmed = typeof window === "undefined" ? true : window.confirm(`从下拉菜单删除工作目录选项?\n${path}\n\n不会删除磁盘上的实际目录。`);
if (!confirmed) return;
setWorkdirBusy(true);
await guarded(async () => {
const result = await requestJson(codexApi(apiBaseUrl, `/api/workdirs/${encodeURIComponent(providerId)}/${encodeURIComponent(executionMode)}/${encodeURIComponent(path)}`), { method: "DELETE" });
setWorkdirData((previous: any) => ({ ...(previous || {}), ...result }));
const msg = `已从下拉菜单删除工作目录:${path}`;
setNotice(msg);
addNotification("success", msg);
}, "删除工作目录失败");
setWorkdirBusy(false);
}
async function createQueue(): Promise<void> {
const proposed = typeof window === "undefined" ? "" : window.prompt("输入新的 Codex queue ID(字母/数字/._-,最长 64", "new-lane");
const nextQueueId = String(proposed || "").trim();
@@ -3420,6 +3513,12 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
void ensureTraceSummary(taskId, true).catch((err) => setError(errorText(err, "自动加载 Trace Summary 失败")));
}, [service?.id, selectedTask?.id, selectedTask?.updatedAt, selectedTask?.traceStats?.statsRevision, selectedTask?._traceSummaryUpdatedAt, selectedTask?._traceSummaryLoaded, selectedDetailLoading]);
useEffect(() => {
if (!service) return undefined;
void loadWorkdirs().catch((err) => setError(errorText(err, "加载工作目录失败")));
return undefined;
}, [service?.id]);
const taskListContent = sidebarTasks.length === 0 ? h(EmptyState, {
title: searchActive ? (searchLoading ? "搜索中" : "没有匹配任务") : "队列为空",
text: searchActive ? (searchLoading ? `正在搜索包含“${normalizedSearchQuery}”的 task...` : `未找到包含“${normalizedSearchQuery}”的 task;可换个关键词或切换 queue。`) : "提交一个任务后,Codex 会串行执行并保存输出。",
@@ -3659,7 +3758,16 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
executionModeRows.map((mode: any) => h("option", { key: mode.id, value: mode.id }, `${mode.label || mode.id}${mode.id === "windows-native" ? " · 宿主 Codex" : ""}`)),
),
),
h("label", null, "工作目录", h("input", { value: cwd, disabled: submitting, onChange: (event: any) => setCwd(event.target.value), placeholder: currentProviderDefaultWorkdir || queue?.defaultWorkdir || "/workspace", "data-testid": "codex-cwd-input" })),
h("label", { className: "codex-workdir-field" }, "工作目录",
h("div", { className: "codex-workdir-row" },
h("input", { value: cwd, disabled: submitting, onChange: (event: any) => setCwd(event.target.value), placeholder: currentProviderDefaultWorkdir || queue?.defaultWorkdir || "/workspace", "data-testid": "codex-cwd-input" }),
h("select", { value: normalizedWorkdirPath(cwd), disabled: submitting || workdirBusy, onChange: (event: any) => setCwd(String(event.target.value || "")), "data-testid": "codex-cwd-select" },
currentWorkdirOptions.map((item: any) => h("option", { key: `${item.providerId}:${item.executionMode}:${item.path}`, value: item.path }, `${item.path}${item.source === "default" ? " · 默认" : ""}`)),
),
h("button", { type: "button", className: "ghost-btn codex-workdir-create-btn", disabled: submitting || busy || workdirBusy, onClick: () => void createWorkdirPreset(), "data-testid": "codex-cwd-create-button" }, workdirBusy ? "处理中" : "新建"),
h("button", { type: "button", className: "ghost-btn codex-workdir-delete-btn", disabled: submitting || busy || workdirBusy || !currentWorkdirSaved, onClick: () => void deleteWorkdirPreset(), title: currentWorkdirSaved ? "从工作目录下拉菜单删除这个选项,不删除磁盘目录" : "当前工作目录尚未保存到下拉菜单", "data-testid": "codex-cwd-delete-button" }, "删除"),
),
),
h("label", null, "最大尝试", h("input", { type: "number", min: 1, max: 99, value: maxAttempts, disabled: submitting, onChange: (event: any) => setMaxAttempts(Number(event.target.value)), "data-testid": "codex-max-attempts-input" })),
h("label", null, "入队份数", h("input", { type: "number", min: 1, max: 50, value: repeatCount, disabled: submitting, onChange: (event: any) => setRepeatCount(Number(event.target.value)), "data-testid": "codex-repeat-count-input" })),
),