feat: add windows elevation status banner

This commit is contained in:
777genius 2026-05-22 17:41:19 +03:00
parent f4ff278ac4
commit abd40efdaf
27 changed files with 585 additions and 4 deletions

BIN
hero-robots-restored.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 KiB

View file

@ -6,10 +6,24 @@ import {
mdiShieldCheckOutline, mdiShieldCheckOutline,
mdiMonitorDashboard, mdiMonitorDashboard,
} from "@mdi/js"; } from "@mdi/js";
import { getLocalizedHeroFeatureRail } from "~/data/heroScene"; import {
heroCollaborationFeature,
getLocalizedHeroFeatureRail,
getLocalizedHeroReviewerFeatureCard,
type HeroMessage,
type HeroMessagePhase,
} from "~/data/heroScene";
const props = defineProps<{
activeMessage?: HeroMessage | null;
phase?: HeroMessagePhase;
reducedMotion?: boolean;
}>();
const { locale } = useI18n(); const { locale } = useI18n();
const localizedHeroFeatureRail = computed(() => getLocalizedHeroFeatureRail(locale.value)); const localizedHeroFeatureRail = computed(() => getLocalizedHeroFeatureRail(locale.value));
const localizedHeroReviewerFeatureCard = computed(() => getLocalizedHeroReviewerFeatureCard(locale.value));
const statusLabel = computed(() => locale.value === "ru" ? "Статус:" : "Status:");
const icons = [ const icons = [
mdiRobotOutline, mdiRobotOutline,
@ -18,10 +32,74 @@ const icons = [
mdiShieldCheckOutline, mdiShieldCheckOutline,
mdiMonitorDashboard, mdiMonitorDashboard,
] as const; ] as const;
const reviewerIsSender = computed(() =>
props.activeMessage?.from === "reviewer" && props.phase !== "cooldown",
);
const reviewerIsReceiver = computed(() =>
props.activeMessage?.to === "reviewer" && props.phase === "receiver",
);
const reviewerIsActive = computed(() => reviewerIsSender.value || reviewerIsReceiver.value);
const reviewerBubbleText = computed(() => {
if (!props.activeMessage || props.reducedMotion) return null;
if (props.activeMessage.from === "reviewer" && (props.phase === "sender" || props.phase === "packet")) {
return props.activeMessage.text;
}
if (props.activeMessage.to === "reviewer" && props.phase === "receiver") {
return props.activeMessage.response;
}
return null;
});
</script> </script>
<template> <template>
<div class="cyber-feature-rail-shell"> <div class="cyber-feature-rail-shell">
<img
class="cyber-feature-rail__collaboration"
:src="heroCollaborationFeature.asset"
alt=""
loading="eager"
fetchpriority="high"
decoding="async"
aria-hidden="true"
>
<div
class="cyber-feature-rail__reviewer"
:class="{
'cyber-feature-rail__reviewer--active': reviewerIsActive,
'cyber-feature-rail__reviewer--sending': reviewerIsSender,
'cyber-feature-rail__reviewer--receiving': reviewerIsReceiver,
}"
aria-hidden="true"
>
<Transition name="cyber-feature-bubble">
<RobotSpeechBubble
v-if="reviewerBubbleText"
class="cyber-feature-rail__reviewer-bubble"
tail="down"
>
{{ reviewerBubbleText }}
</RobotSpeechBubble>
</Transition>
<div class="cyber-feature-rail__reviewer-card cyber-panel">
<div class="cyber-feature-rail__reviewer-label">{{ localizedHeroReviewerFeatureCard.label }}</div>
<ul class="cyber-feature-rail__reviewer-tasks">
<li v-for="task in localizedHeroReviewerFeatureCard.tasks" :key="task">{{ task }}</li>
</ul>
<div class="cyber-feature-rail__reviewer-status">
<span>{{ statusLabel }}</span>
<strong>{{ localizedHeroReviewerFeatureCard.status }}</strong>
</div>
</div>
<img
class="cyber-feature-rail__robot"
:src="localizedHeroReviewerFeatureCard.asset"
alt=""
loading="eager"
fetchpriority="high"
decoding="async"
>
</div>
<div class="cyber-feature-rail"> <div class="cyber-feature-rail">
<div <div
v-for="(feature, index) in localizedHeroFeatureRail" v-for="(feature, index) in localizedHeroFeatureRail"

View file

@ -252,6 +252,9 @@ onUnmounted(() => {
<CyberHeroFeatureStrip <CyberHeroFeatureStrip
class="cyber-hero__feature-strip" class="cyber-hero__feature-strip"
:active-message="activeHeroMessage"
:phase="heroMessagePhase"
:reduced-motion="heroReducedMotion"
/> />
</v-container> </v-container>
</section> </section>

View file

@ -8,7 +8,7 @@ export type Screenshot = {
/** /**
* Screenshot definitions for the carousel. * Screenshot definitions for the carousel.
* `src` is relative to public/ prepend baseURL at runtime. * `src` is served from the repository-level docs/screenshots directory.
*/ */
export const screenshots: (Omit<Screenshot, "src"> & { path: string })[] = [ export const screenshots: (Omit<Screenshot, "src"> & { path: string })[] = [
{ path: "screenshots/1.jpg", alt: "Kanban board with agent tasks", ruAlt: "Канбан-доска с задачами агентов", width: 1920, height: 1080 }, { path: "screenshots/1.jpg", alt: "Kanban board with agent tasks", ruAlt: "Канбан-доска с задачами агентов", width: 1920, height: 1080 },
@ -21,6 +21,4 @@ export const screenshots: (Omit<Screenshot, "src"> & { path: string })[] = [
{ path: "screenshots/8.png", alt: "Task details and comments", ruAlt: "Детали задачи и комментарии", width: 1920, height: 1080 }, { path: "screenshots/8.png", alt: "Task details and comments", ruAlt: "Детали задачи и комментарии", width: 1920, height: 1080 },
{ path: "screenshots/9.png", alt: "Built-in code editor", ruAlt: "Встроенный редактор кода", width: 1920, height: 1080 }, { path: "screenshots/9.png", alt: "Built-in code editor", ruAlt: "Встроенный редактор кода", width: 1920, height: 1080 },
{ path: "screenshots/10.png", alt: "Task details with code changes and execution logs", ruAlt: "Детали задачи с изменениями кода и логами выполнения", width: 2624, height: 1642 }, { path: "screenshots/10.png", alt: "Task details with code changes and execution logs", ruAlt: "Детали задачи с изменениями кода и логами выполнения", width: 2624, height: 1642 },
{ path: "screenshots/11.png", alt: "Agent code review comments and task workflow", ruAlt: "Комментарии агента к код-ревью и процессу задачи", width: 2624, height: 1696 },
{ path: "screenshots/12.png", alt: "Allow or deny agent actions with live preview", ruAlt: "Разрешение или запрет действий агента с предпросмотром", width: 2624, height: 1646 },
]; ];

View file

@ -1,3 +1,5 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import vuetify from "vite-plugin-vuetify"; import vuetify from "vite-plugin-vuetify";
import { generateI18nRoutes, supportedLocales } from "./data/i18n"; import { generateI18nRoutes, supportedLocales } from "./data/i18n";
@ -12,6 +14,7 @@ const muxPlaybackId = process.env.NUXT_PUBLIC_MUX_PLAYBACK_ID || "qyeNuDjFqoDALK
const muxBackgroundPlaybackId = process.env.NUXT_PUBLIC_MUX_BACKGROUND_PLAYBACK_ID || muxPlaybackId; const muxBackgroundPlaybackId = process.env.NUXT_PUBLIC_MUX_BACKGROUND_PLAYBACK_ID || muxPlaybackId;
const baseURL = process.env.NUXT_APP_BASE_URL || "/"; const baseURL = process.env.NUXT_APP_BASE_URL || "/";
const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`; const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`;
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const defaultSeoTitle = "Agent Teams - AI Agent Orchestration for Developers"; const defaultSeoTitle = "Agent Teams - AI Agent Orchestration for Developers";
const defaultSeoDescription = "Free, open-source desktop app for AI agent teams. Start with a free model with no auth, then connect Claude, Codex, or OpenCode when you need more models."; const defaultSeoDescription = "Free, open-source desktop app for AI agent teams. Start with a free model with no auth, then connect Claude, Codex, or OpenCode when you need more models.";
const defaultSeoImage = `${siteUrl.replace(/\/+$/, "")}/og-image-agent-teams-v5.png`; const defaultSeoImage = `${siteUrl.replace(/\/+$/, "")}/og-image-agent-teams-v5.png`;
@ -82,6 +85,13 @@ export default defineNuxtConfig({
}, },
nitro: { nitro: {
compressPublicAssets: true, compressPublicAssets: true,
publicAssets: [
{
baseURL: "/screenshots",
dir: resolve(repoRoot, "docs/screenshots"),
maxAge: 60 * 60 * 24 * 365
}
],
prerender: { prerender: {
ignore: [ ignore: [
"/docs", "/docs",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 664 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 666 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 770 KiB

View file

@ -88,7 +88,9 @@ import {
} from '@main/services/team/TeamMcpConfigBuilder'; } from '@main/services/team/TeamMcpConfigBuilder';
import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver'; import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver';
import { killTrackedCliProcesses } from '@main/utils/childProcess'; import { killTrackedCliProcesses } from '@main/utils/childProcess';
import { getWindowsElevationStatus } from '@main/utils/windowsElevation';
import { import {
APP_GET_WINDOWS_ELEVATION_STATUS,
APP_STARTUP_GET_STATUS, APP_STARTUP_GET_STATUS,
APP_STARTUP_PROGRESS, APP_STARTUP_PROGRESS,
CONTEXT_CHANGED, CONTEXT_CHANGED,
@ -975,6 +977,7 @@ function registerAppStartupHandlers(): void {
appStartupHandlersRegistered = true; appStartupHandlersRegistered = true;
registerRendererLogHandlers(ipcMain); registerRendererLogHandlers(ipcMain);
ipcMain.handle(APP_STARTUP_GET_STATUS, () => appStartupStatus); ipcMain.handle(APP_STARTUP_GET_STATUS, () => appStartupStatus);
ipcMain.handle(APP_GET_WINDOWS_ELEVATION_STATUS, () => getWindowsElevationStatus());
} }
function cloneStartupSteps(): AppStartupStep[] { function cloneStartupSteps(): AppStartupStep[] {

View file

@ -0,0 +1,145 @@
import { execFile } from 'child_process';
import { win32 as pathWin32 } from 'path';
import type { WindowsElevationStatus } from '@shared/types/api';
const DEFAULT_WINDOWS_ELEVATION_TIMEOUT_MS = 3_000;
const DEFAULT_WINDOWS_SYSTEM_ROOT = 'C:\\Windows';
export interface WindowsElevationCommandResult {
error: unknown;
stderr?: string | Buffer | null;
}
export interface WindowsElevationCommandOptions {
timeoutMs: number;
}
export type WindowsElevationCommandRunner = (
command: string,
options: WindowsElevationCommandOptions
) => Promise<WindowsElevationCommandResult>;
export interface WindowsElevationStatusCheckerOptions {
platform?: string;
systemRoot?: string;
timeoutMs?: number;
runCommand?: WindowsElevationCommandRunner;
}
let cachedWindowsElevationStatus: Promise<WindowsElevationStatus> | null = null;
function createStatus(
platform: string,
isAdministrator: boolean | null,
checkFailed: boolean,
error: string | null = null
): WindowsElevationStatus {
return {
platform,
isWindows: platform === 'win32',
isAdministrator,
checkFailed,
error,
};
}
function readErrorField(error: unknown, field: string): unknown {
if (!error || typeof error !== 'object' || !(field in error)) {
return undefined;
}
return (error as Record<string, unknown>)[field];
}
function getErrorCode(error: unknown): string | number | null {
const code = readErrorField(error, 'code');
return typeof code === 'string' || typeof code === 'number' ? code : null;
}
function wasKilledOrTimedOut(error: unknown): boolean {
const killed = readErrorField(error, 'killed');
const signal = readErrorField(error, 'signal');
const code = getErrorCode(error);
return killed === true || signal === 'SIGTERM' || code === 'ETIMEDOUT';
}
function toCappedString(value: unknown): string | null {
if (typeof value === 'string') {
return value.slice(0, 500);
}
if (Buffer.isBuffer(value)) {
return value.toString('utf8').slice(0, 500);
}
return null;
}
function getErrorMessage(error: unknown, stderr: unknown): string | null {
const stderrText = toCappedString(stderr)?.trim();
if (stderrText) {
return stderrText;
}
if (error instanceof Error && error.message.trim()) {
return error.message.slice(0, 500);
}
return null;
}
function getFltmcPath(systemRoot: string): string {
return pathWin32.join(systemRoot, 'System32', 'fltmc.exe');
}
function runFltmc(command: string, options: WindowsElevationCommandOptions) {
return new Promise<WindowsElevationCommandResult>((resolve) => {
execFile(
command,
[],
{ timeout: options.timeoutMs, windowsHide: true },
(error, _stdout, stderr) => {
resolve({ error, stderr });
}
);
});
}
export function createWindowsElevationStatusChecker(
options: WindowsElevationStatusCheckerOptions = {}
): () => Promise<WindowsElevationStatus> {
const platform = options.platform ?? process.platform;
const systemRoot = options.systemRoot ?? process.env.SystemRoot ?? DEFAULT_WINDOWS_SYSTEM_ROOT;
const timeoutMs = options.timeoutMs ?? DEFAULT_WINDOWS_ELEVATION_TIMEOUT_MS;
const runCommand = options.runCommand ?? runFltmc;
return async () => {
if (platform !== 'win32') {
return createStatus(platform, null, false);
}
let result: WindowsElevationCommandResult;
try {
result = await runCommand(getFltmcPath(systemRoot), { timeoutMs });
} catch (error) {
return createStatus(platform, null, true, getErrorMessage(error, null));
}
if (!result.error) {
return createStatus(platform, true, false);
}
const code = getErrorCode(result.error);
const message = getErrorMessage(result.error, result.stderr);
if (code === 'ENOENT' || wasKilledOrTimedOut(result.error)) {
return createStatus(platform, null, true, message);
}
return createStatus(platform, false, false, message);
};
}
export function getWindowsElevationStatus(): Promise<WindowsElevationStatus> {
cachedWindowsElevationStatus ??= createWindowsElevationStatusChecker()();
return cachedWindowsElevationStatus;
}
export function resetWindowsElevationStatusCacheForTests(): void {
cachedWindowsElevationStatus = null;
}

View file

@ -23,6 +23,9 @@ export const APP_STARTUP_GET_STATUS = 'appStartup:getStatus';
/** Main -> renderer startup progress update */ /** Main -> renderer startup progress update */
export const APP_STARTUP_PROGRESS = 'appStartup:progress'; export const APP_STARTUP_PROGRESS = 'appStartup:progress';
/** Renderer -> main Windows elevation status request */
export const APP_GET_WINDOWS_ELEVATION_STATUS = 'app:getWindowsElevationStatus';
// ============================================================================= // =============================================================================
// Telemetry Channels // Telemetry Channels
// ============================================================================= // =============================================================================

View file

@ -14,6 +14,7 @@ import {
API_KEYS_LOOKUP, API_KEYS_LOOKUP,
API_KEYS_SAVE, API_KEYS_SAVE,
API_KEYS_STORAGE_STATUS, API_KEYS_STORAGE_STATUS,
APP_GET_WINDOWS_ELEVATION_STATUS,
APP_RELAUNCH, APP_RELAUNCH,
APP_STARTUP_GET_STATUS, APP_STARTUP_GET_STATUS,
APP_STARTUP_PROGRESS, APP_STARTUP_PROGRESS,
@ -350,6 +351,7 @@ import type {
TriggerTestResult, TriggerTestResult,
UpdateKanbanPatch, UpdateKanbanPatch,
UpdateSchedulePatch, UpdateSchedulePatch,
WindowsElevationStatus,
WslClaudeRootCandidate, WslClaudeRootCandidate,
} from '@shared/types'; } from '@shared/types';
import type { import type {
@ -514,6 +516,8 @@ const electronAPI: ElectronAPI = {
}, },
}, },
getAppVersion: () => ipcRenderer.invoke('get-app-version'), getAppVersion: () => ipcRenderer.invoke('get-app-version'),
getWindowsElevationStatus: () =>
ipcRenderer.invoke(APP_GET_WINDOWS_ELEVATION_STATUS) as Promise<WindowsElevationStatus>,
getProjects: () => ipcRenderer.invoke('get-projects'), getProjects: () => ipcRenderer.invoke('get-projects'),
getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId), getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId),
getSessionsPaginated: ( getSessionsPaginated: (

View file

@ -98,6 +98,7 @@ import type {
UpdaterAPI, UpdaterAPI,
UpdateSchedulePatch, UpdateSchedulePatch,
WaterfallData, WaterfallData,
WindowsElevationStatus,
WslClaudeRootCandidate, WslClaudeRootCandidate,
} from '@shared/types'; } from '@shared/types';
import type { AgentConfig, MemberWorkSyncElectronApi } from '@shared/types/api'; import type { AgentConfig, MemberWorkSyncElectronApi } from '@shared/types/api';
@ -244,6 +245,14 @@ export class HttpAPIClient implements ElectronAPI {
getAppVersion = (): Promise<string> => this.get<string>('/api/version'); getAppVersion = (): Promise<string> => this.get<string>('/api/version');
getWindowsElevationStatus = async (): Promise<WindowsElevationStatus> => ({
platform: 'browser',
isWindows: false,
isAdministrator: null,
checkFailed: false,
error: null,
});
getCodexAccountSnapshot = (): Promise<CodexAccountSnapshotDto> => getCodexAccountSnapshot = (): Promise<CodexAccountSnapshotDto> =>
Promise.reject(new Error('Codex account bridge is unavailable in browser mode')); Promise.reject(new Error('Codex account bridge is unavailable in browser mode'));

View file

@ -16,6 +16,7 @@ import { CliStatusBanner } from './CliStatusBanner';
import { DashboardUpdateBanner } from './DashboardUpdateBanner'; import { DashboardUpdateBanner } from './DashboardUpdateBanner';
import { TmuxStatusBanner } from './TmuxStatusBanner'; import { TmuxStatusBanner } from './TmuxStatusBanner';
import { WebPreviewBanner } from './WebPreviewBanner'; import { WebPreviewBanner } from './WebPreviewBanner';
import { WindowsAdministratorBanner } from './WindowsAdministratorBanner';
interface CommandSearchProps { interface CommandSearchProps {
value: string; value: string;
@ -114,6 +115,7 @@ export const DashboardView = (): React.JSX.Element => {
<div className="relative mx-auto max-w-5xl px-8 py-12"> <div className="relative mx-auto max-w-5xl px-8 py-12">
<WebPreviewBanner /> <WebPreviewBanner />
<WindowsAdministratorBanner />
<DashboardUpdateBanner /> <DashboardUpdateBanner />
<CliStatusBanner /> <CliStatusBanner />
<TmuxStatusBanner /> <TmuxStatusBanner />

View file

@ -0,0 +1,134 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { WindowsAdministratorBanner } from './WindowsAdministratorBanner';
import type { WindowsElevationStatus } from '@shared/types/api';
function createStatus(overrides: Partial<WindowsElevationStatus> = {}): WindowsElevationStatus {
return {
platform: 'win32',
isWindows: true,
isAdministrator: false,
checkFailed: false,
error: null,
...overrides,
};
}
function installElevationStatus(status: WindowsElevationStatus) {
const getWindowsElevationStatus = vi.fn().mockResolvedValue(status);
Object.defineProperty(window, 'electronAPI', {
configurable: true,
value: {
getWindowsElevationStatus,
},
});
return getWindowsElevationStatus;
}
async function flushReact(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe('WindowsAdministratorBanner', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
});
afterEach(() => {
document.body.innerHTML = '';
Reflect.deleteProperty(window, 'electronAPI');
vi.unstubAllGlobals();
});
it('shows a Windows Administrator warning when the app is not elevated', async () => {
const getWindowsElevationStatus = installElevationStatus(createStatus());
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(WindowsAdministratorBanner));
await flushReact();
});
expect(getWindowsElevationStatus).toHaveBeenCalledTimes(1);
expect(host.textContent).toContain('Windows Administrator mode recommended');
expect(host.textContent).toContain('Run as administrator');
await act(async () => {
root.unmount();
await flushReact();
});
});
it('hides the warning when Windows is already elevated', async () => {
const getWindowsElevationStatus = installElevationStatus(
createStatus({ isAdministrator: true })
);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(WindowsAdministratorBanner));
await flushReact();
});
expect(getWindowsElevationStatus).toHaveBeenCalledTimes(1);
expect(host.textContent).toBe('');
await act(async () => {
root.unmount();
await flushReact();
});
});
it('hides the warning outside Windows', async () => {
const getWindowsElevationStatus = installElevationStatus(
createStatus({ platform: 'darwin', isWindows: false, isAdministrator: null })
);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(WindowsAdministratorBanner));
await flushReact();
});
expect(getWindowsElevationStatus).toHaveBeenCalledTimes(1);
expect(host.textContent).toBe('');
await act(async () => {
root.unmount();
await flushReact();
});
});
it('hides the warning when the preload bridge does not expose the status check', async () => {
Object.defineProperty(window, 'electronAPI', {
configurable: true,
value: {},
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(WindowsAdministratorBanner));
await flushReact();
});
expect(host.textContent).toBe('');
await act(async () => {
root.unmount();
await flushReact();
});
});
});

View file

@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { api, isElectronMode } from '@renderer/api';
import { AlertTriangle } from 'lucide-react';
import type { WindowsElevationStatus } from '@shared/types/api';
export const WindowsAdministratorBanner = (): React.JSX.Element | null => {
const [status, setStatus] = useState<WindowsElevationStatus | null>(null);
useEffect(() => {
if (!isElectronMode()) {
return undefined;
}
const getStatus = api.getWindowsElevationStatus;
if (typeof getStatus !== 'function') {
return undefined;
}
let cancelled = false;
void getStatus()
.then((nextStatus) => {
if (!cancelled) {
setStatus(nextStatus);
}
})
.catch(() => {
if (!cancelled) {
setStatus(null);
}
});
return () => {
cancelled = true;
};
}, []);
if (!status?.isWindows || status.isAdministrator !== false) {
return null;
}
return (
<div
className="mb-6 flex items-start gap-3 rounded-lg border px-4 py-3"
role="status"
style={{
borderColor: 'rgba(245, 158, 11, 0.35)',
backgroundColor: 'rgba(245, 158, 11, 0.07)',
}}
>
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-amber-200">
Windows Administrator mode recommended
</div>
<p className="mt-1 text-xs leading-5" style={{ color: 'var(--color-text-secondary)' }}>
OpenCode runtime checks can time out when Agent Teams AI is not elevated. Restart the app
with Run as administrator before launching OpenCode teams.
</p>
</div>
</div>
);
};

View file

@ -806,6 +806,14 @@ export interface TelemetryAPI {
getSentryContext: () => Promise<SentryTelemetryContext | null>; getSentryContext: () => Promise<SentryTelemetryContext | null>;
} }
export interface WindowsElevationStatus {
platform: string;
isWindows: boolean;
isAdministrator: boolean | null;
checkFailed: boolean;
error: string | null;
}
// ============================================================================= // =============================================================================
// Main Electron API // Main Electron API
// ============================================================================= // =============================================================================
@ -817,6 +825,7 @@ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElec
startup?: AppStartupAPI; startup?: AppStartupAPI;
telemetry: TelemetryAPI; telemetry: TelemetryAPI;
getAppVersion: () => Promise<string>; getAppVersion: () => Promise<string>;
getWindowsElevationStatus: () => Promise<WindowsElevationStatus>;
getProjects: () => Promise<Project[]>; getProjects: () => Promise<Project[]>;
getSessions: (projectId: string) => Promise<Session[]>; getSessions: (projectId: string) => Promise<Session[]>;
getSessionsPaginated: ( getSessionsPaginated: (

View file

@ -0,0 +1,119 @@
// @vitest-environment node
import {
createWindowsElevationStatusChecker,
resetWindowsElevationStatusCacheForTests,
} from '@main/utils/windowsElevation';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { WindowsElevationCommandRunner } from '@main/utils/windowsElevation';
function createError(
message: string,
fields: { code?: string | number; killed?: boolean; signal?: string | null } = {}
): Error & { code?: string | number; killed?: boolean; signal?: string | null } {
return Object.assign(new Error(message), fields);
}
describe('windowsElevation', () => {
afterEach(() => {
resetWindowsElevationStatusCacheForTests();
});
it('does not run the elevation command outside Windows', async () => {
const runCommand = vi.fn<WindowsElevationCommandRunner>();
const status = await createWindowsElevationStatusChecker({
platform: 'darwin',
runCommand,
})();
expect(runCommand).not.toHaveBeenCalled();
expect(status).toEqual({
platform: 'darwin',
isWindows: false,
isAdministrator: null,
checkFailed: false,
error: null,
});
});
it('reports Administrator mode when fltmc succeeds', async () => {
const runCommand = vi
.fn<WindowsElevationCommandRunner>()
.mockResolvedValue({ error: null });
const status = await createWindowsElevationStatusChecker({
platform: 'win32',
systemRoot: 'C:\\Windows',
runCommand,
})();
expect(runCommand).toHaveBeenCalledWith('C:\\Windows\\System32\\fltmc.exe', {
timeoutMs: 3_000,
});
expect(status.isAdministrator).toBe(true);
expect(status.checkFailed).toBe(false);
});
it('reports non-elevated Windows when fltmc exits with an error', async () => {
const runCommand = vi.fn<WindowsElevationCommandRunner>().mockResolvedValue({
error: createError('Command failed', { code: 1 }),
stderr: 'Access is denied.',
});
const status = await createWindowsElevationStatusChecker({
platform: 'win32',
runCommand,
})();
expect(status.isWindows).toBe(true);
expect(status.isAdministrator).toBe(false);
expect(status.checkFailed).toBe(false);
expect(status.error).toBe('Access is denied.');
});
it('reports an unknown status when the Windows probe command is missing', async () => {
const runCommand = vi.fn<WindowsElevationCommandRunner>().mockResolvedValue({
error: createError('spawn fltmc.exe ENOENT', { code: 'ENOENT' }),
});
const status = await createWindowsElevationStatusChecker({
platform: 'win32',
runCommand,
})();
expect(status.isAdministrator).toBeNull();
expect(status.checkFailed).toBe(true);
expect(status.error).toContain('ENOENT');
});
it('reports an unknown status when the Windows probe times out', async () => {
const runCommand = vi.fn<WindowsElevationCommandRunner>().mockResolvedValue({
error: createError('Command timed out', { code: 'ETIMEDOUT', killed: true }),
});
const status = await createWindowsElevationStatusChecker({
platform: 'win32',
runCommand,
})();
expect(status.isAdministrator).toBeNull();
expect(status.checkFailed).toBe(true);
expect(status.error).toContain('Command timed out');
});
it('reports an unknown status when the Windows probe throws before returning a result', async () => {
const runCommand = vi
.fn<WindowsElevationCommandRunner>()
.mockRejectedValue(new Error('spawn failed'));
const status = await createWindowsElevationStatusChecker({
platform: 'win32',
runCommand,
})();
expect(status.isAdministrator).toBeNull();
expect(status.checkFailed).toBe(true);
expect(status.error).toBe('spawn failed');
});
});