feat: complete ops ai workspace bridge

This commit is contained in:
DCCONSTRUCTIONS
2026-06-13 08:46:55 +03:00
parent 8b1e356494
commit 3806848b07
7 changed files with 2040 additions and 45 deletions
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,8 @@ import type {
TAIWorkspaceExecutorConnectionMode,
TAIWorkspaceExecutorInput,
TAIWorkspaceExecutorListResponse,
TAIWorkspaceOpsProject,
TAIWorkspaceOpsWorkspace,
} from "@/services/workspace-ai-workspace.service";
const DEFAULT_WINDOWS_AGENT_PORT = "8787";
@@ -43,8 +45,15 @@ type TExecutorDraft = {
type TAIWorkspaceProductSettingsModalProps = {
isOpen: boolean;
workspaceSlug: string;
executors: TAIWorkspaceExecutor[];
selectedExecutorId?: string | null;
workspaceOptions: TAIWorkspaceOpsWorkspace[];
selectedOpsWorkspaceSlug: string;
workspaceLoadError?: string;
projectOptions: TAIWorkspaceOpsProject[];
selectedProjectId: string;
projectLoadError?: string;
isLoading?: boolean;
error?: unknown;
notice?: string;
@@ -53,11 +62,17 @@ type TAIWorkspaceProductSettingsModalProps = {
onClose: () => void;
onReload: () => Promise<unknown> | unknown;
onSelect: (executor: TAIWorkspaceExecutor) => Promise<unknown> | unknown;
onCheck: (executor: TAIWorkspaceExecutor) => Promise<TAIWorkspaceExecutorListResponse | null>;
onCreate: (input: TAIWorkspaceExecutorInput) => Promise<TAIWorkspaceExecutorListResponse | null>;
onUpdate: (executorId: string, input: TAIWorkspaceExecutorInput) => Promise<TAIWorkspaceExecutorListResponse | null>;
onDelete: (executorId: string) => Promise<TAIWorkspaceExecutorListResponse | null>;
onOpsWorkspaceChange: (workspaceSlug: string) => void;
onProjectChange: (projectId: string) => void;
onDownloadAgent: (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => Promise<void>;
getWindowsAgentInstallerUrl: (executorId: string, options?: { port?: string | number }) => string;
getWindowsAgentInstallerUrl: (
executorId: string,
options?: { port?: string | number; opsWorkspaceSlug?: string; opsProjectId?: string }
) => string;
};
function emptyDraft(): TExecutorDraft {
@@ -108,11 +123,37 @@ function statusLabel(status: string | null | undefined) {
return "Unknown";
}
function formatCheckTime(value: string | null | undefined) {
if (!value) return "";
const time = Date.parse(value);
if (!Number.isFinite(time)) return "";
return new Date(time).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function errorText(error: unknown) {
if (!error) return "";
return String((error as any)?.message || (error as any)?.error || error);
}
function projectLabel(project: TAIWorkspaceOpsProject | null | undefined) {
if (!project) return "";
return String(project.title || project.name || project.identifier || project.projectIdentifier || project.id || "").trim();
}
function projectIdentifier(project: TAIWorkspaceOpsProject | null | undefined) {
return String(project?.identifier || project?.projectIdentifier || "").trim();
}
function workspaceLabel(workspace: TAIWorkspaceOpsWorkspace | null | undefined) {
if (!workspace) return "";
return String(workspace.title || workspace.name || workspace.slug || workspace.id || "").trim();
}
function AIWorkspaceGlassSelect<T extends string>({
value,
options,
@@ -260,8 +301,15 @@ function AIWorkspaceGlassSelect<T extends string>({
export function AIWorkspaceProductSettingsModal({
isOpen,
workspaceSlug,
executors,
selectedExecutorId,
workspaceOptions,
selectedOpsWorkspaceSlug,
workspaceLoadError,
projectOptions,
selectedProjectId,
projectLoadError,
isLoading,
error,
notice,
@@ -270,9 +318,12 @@ export function AIWorkspaceProductSettingsModal({
onClose,
onReload,
onSelect,
onCheck,
onCreate,
onUpdate,
onDelete,
onOpsWorkspaceChange,
onProjectChange,
onDownloadAgent,
getWindowsAgentInstallerUrl,
}: TAIWorkspaceProductSettingsModalProps) {
@@ -290,6 +341,37 @@ export function AIWorkspaceProductSettingsModal({
);
const detailsOpen = detailsMode === "new" || detailsMode === "edit";
const isBusy = Boolean(busyId);
const selectedProject = useMemo(
() => projectOptions.find((project) => project.id === selectedProjectId) || null,
[projectOptions, selectedProjectId]
);
const selectedOpsWorkspace = useMemo(
() => workspaceOptions.find((workspace) => workspace.slug === selectedOpsWorkspaceSlug) || null,
[selectedOpsWorkspaceSlug, workspaceOptions]
);
const workspaceSelectOptions = useMemo(
() =>
workspaceOptions.map((workspace) => ({
value: workspace.slug,
label: workspaceLabel(workspace),
})),
[workspaceOptions]
);
const projectSelectOptions = useMemo(
() => [
{ value: "", label: "Не выбран" },
...projectOptions.map((project) => {
const identifier = projectIdentifier(project);
const label = projectLabel(project);
return {
value: project.id,
label: identifier ? `${label} · ${identifier}` : label,
};
}),
],
[projectOptions]
);
const contextReady = Boolean(activeExecutor && selectedOpsWorkspace && selectedProject);
useEffect(() => {
if (!isOpen) {
@@ -321,6 +403,11 @@ export function AIWorkspaceProductSettingsModal({
setDraft(draftFromExecutor(activeExecutor));
};
const checkActive = () => {
if (!activeExecutor) return;
void onCheck(activeExecutor);
};
const saveDraft = async (event: FormEvent) => {
event.preventDefault();
const input = toInput(draft);
@@ -363,6 +450,10 @@ export function AIWorkspaceProductSettingsModal({
await onDownloadAgent(editingExecutor.id, toInput(draft), draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT);
};
const activeCheckDetails = activeExecutor
? [formatCheckTime(activeExecutor.lastSeenAt), activeExecutor.statusDetail].filter(Boolean).join(" · ")
: "";
return (
<ModalCore
isOpen={isOpen}
@@ -451,18 +542,62 @@ export function AIWorkspaceProductSettingsModal({
<div>
<span>Активное устройство</span>
<b>{activeExecutor.name}</b>
<small>Codex worker · Codex Remote</small>
<small>Codex worker · Ops</small>
</div>
</div>
<div className="ai-settings-context-note ai-settings-context-note--remote">
Прямое управление Codex-сеансами устройства.
<div className="ai-settings-field-row">
<label className="ai-settings-field">
<span>Mode</span>
<input className="ai-settings-input" value="Ops" readOnly />
</label>
<label className="ai-settings-field">
<span>Workspace</span>
<AIWorkspaceGlassSelect
value={selectedOpsWorkspaceSlug}
options={workspaceSelectOptions}
ariaLabel="Ops workspace"
disabled={workspaceSelectOptions.length <= 1}
onChange={onOpsWorkspaceChange}
/>
</label>
</div>
{workspaceLoadError ? <div className="ai-settings-error">{workspaceLoadError}</div> : null}
<label className="ai-settings-field">
<span>Ops project</span>
<AIWorkspaceGlassSelect
value={selectedProjectId}
options={projectSelectOptions}
ariaLabel="Ops project"
disabled={!selectedOpsWorkspaceSlug || projectOptions.length === 0}
onChange={onProjectChange}
/>
</label>
{projectLoadError ? <div className="ai-settings-error">{projectLoadError}</div> : null}
<div
className="ai-settings-context-note"
data-ready={contextReady ? "true" : undefined}
>
{contextReady
? `Ops-контекст готов: ${workspaceLabel(selectedOpsWorkspace)} / ${projectLabel(selectedProject)}`
: "Для записи задач в Ops выбери project. Чат доступен всегда, но запись задач будет ограничена выбранным проектом."}
</div>
{activeCheckDetails ? (
<div className="ai-settings-check-note">Last check: {activeCheckDetails}</div>
) : null}
<div className="modal-actions ai-settings-actions">
<button type="button" className="modal-btn" disabled={isBusy} onClick={editActive}>
Настройки
</button>
<button type="button" className="modal-btn" disabled={isBusy} onClick={checkActive}>
{busyId === `check:${activeExecutor.id}` ? "Проверка..." : "Проверить"}
</button>
<button type="button" className="modal-btn btn-primary" onClick={onClose}>
Готово
</button>