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
116 changes: 116 additions & 0 deletions devlog/_plan/261110_desktop_autostart_checklist/000_plan.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions docs-site/src/content/docs/guides/desktop-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,13 @@ when they verify that the app runs its bundled proxy, even without recorded owne
CLI service repairs for that runtime. On macOS and Linux, turn on **Start at Login**
in the OpenCodex menu instead of installing a background service. Startup safety credits the app only when its login registration is verified;
if it cannot be verified, follow the Desktop guidance in status.
In the dashboard's Getting started checklist, the last step under the app is
**Turn on Start at Login** and opens the Desktop card on the Startup page. It is marked
done only when Startup safety verifies the registration and the app runs the proxy. It
shows as to do when Start at Login is verified off, or on macOS when the app is confirmed
not to be running the proxy. Otherwise the step stays unmarked, which is the usual case on
Windows (the registration is not verified) and on Linux without a verified autostart
entry; check the item in the OpenCodex menu yourself.
The Startup page shows a quiet **Start at Login not verified** note when the app's
registration cannot be checked. A verified disabled registration is shown as action required.
On macOS the menu's **Start at Login** item is checked only when Startup safety would
Expand Down
1 change: 1 addition & 0 deletions gui/src/app-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ export function hashBelongsToPage(rawHash: string, page: Page): boolean {
return rawHash === page
|| (page === "claude" && ["claude/code", "claude/desktop"].includes(rawHash))
|| (page === "logs" && rawHash === "logs/debug")
|| (page === "startup" && rawHash === "startup/desktop")
|| (page === "usage" && rawHash === "usage/companion")
|| (page === "codex-set" && (rawHash === "codex-set/prompt" || rawHash === "codex-set/desktop"))
|| (page === "models" && (
Expand Down
4 changes: 2 additions & 2 deletions gui/src/hooks/useStartupChecklist.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useClientResource, useKeyedClientResource } from "../client-resource";
import { STARTUP_HEALTH_STALE_RETRY_MS, startupChecklistDone } from "../startup-health-ui";
import { STARTUP_HEALTH_STALE_RETRY_MS, type StartupChecklistBody } from "../startup-health-ui";

type StartupBody = Parameters<typeof startupChecklistDone>[0];
type StartupBody = StartupChecklistBody;

export function useStartupChecklist(apiBase: string) {
const key = `home-startup:${apiBase}`;
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/shell-v2-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ const rows = {
stepFirstRequestHint: ["Point Codex, Claude Code or another tool at opencodex, then ask it anything.", "Codex나 Claude Code 같은 도구를 opencodex에 연결하고 아무거나 물어보세요.", "Codex, Claude Code oder ein anderes Tool auf opencodex richten und etwas fragen.", "Branchez Codex, Claude Code ou un autre outil sur opencodex, puis posez une question.", "把 Codex、Claude Code 等工具连到 opencodex,然后随便问一句。", "把 Codex、Claude Code 等工具連到 opencodex,然後隨便問一句。", "Направьте Codex, Claude Code или другой инструмент на opencodex и задайте вопрос.", "Codex や Claude Code などを opencodex につなぎ、何か質問してみましょう。", "Codex, Claude Code veya başka bir aracı opencodex’e yönlendirip bir şey sorun.", "Trỏ Codex, Claude Code hoặc công cụ khác tới opencodex rồi hỏi bất cứ điều gì.", "Aponte o Codex, o Claude Code ou outra ferramenta para o opencodex e pergunte algo."],
stepAutostart: ["Start automatically", "자동 시작 켜기", "Automatisch starten", "Démarrer automatiquement", "开机自动启动", "開機自動啟動", "Запускать автоматически", "自動で起動", "Otomatik başlat", "Tự khởi động", "Iniciar automaticamente"],
stepAutostartHint: ["Keep opencodex running after a restart so your tools never lose it.", "컴퓨터를 다시 켜도 opencodex가 알아서 켜지게 해요.", "opencodex nach einem Neustart weiterlaufen lassen, damit Tools es nie verlieren.", "Gardez opencodex actif après un redémarrage pour que vos outils ne le perdent jamais.", "重启后 opencodex 仍会运行,工具不会断开。", "重新開機後 opencodex 仍會執行,工具不會斷線。", "opencodex продолжит работать после перезагрузки.", "再起動後も opencodex が動き続けるようにします。", "opencodex yeniden başlatmadan sonra da çalışsın.", "Giữ opencodex chạy sau khi khởi động lại.", "Mantenha o opencodex rodando após reiniciar."],
stepAutostartDesktop: ["Turn on Start at Login", "로그인 시 시작 켜기", "Start beim Anmelden einschalten", "Activer le démarrage à la connexion", "开启登录时启动", "開啟登入時啟動", "Включить запуск при входе", "ログイン時の起動をオンにする", "Oturum açılışında başlatmayı aç", "Bật khởi động khi đăng nhập", "Ativar Iniciar no login"],
stepAutostartDesktopHint: ["In the OpenCodex app menu, make sure Start at Login is checked. The app then opens when you log in and keeps the proxy running.", "OpenCodex 앱 메뉴에서 '로그인 시 시작'이 켜져 있는지 확인하세요. 로그인할 때 앱이 열리고 프록시도 계속 동작해요.", "Prüfe im Menü der OpenCodex-App, dass „Beim Anmelden starten“ aktiviert ist. Dann öffnet sich die App beim Anmelden und der Proxy läuft weiter.", "Dans le menu de l’app OpenCodex, vérifiez que le démarrage à la connexion est coché. L’app s’ouvre alors à la connexion et le proxy reste actif.", "请在 OpenCodex 应用菜单中确认已勾选“登录时启动”。这样登录时应用会自动打开,代理保持运行。", "請在 OpenCodex 應用程式選單中確認已勾選「登入時啟動」。這樣登入時應用程式會自動開啟,代理持續執行。", "Убедитесь, что в меню приложения OpenCodex отмечен запуск при входе. Тогда приложение откроется при входе, а прокси продолжит работать.", "OpenCodex アプリのメニューで「ログイン時の起動」がオンになっているか確認してください。ログイン時にアプリが開き、プロキシが動き続けます。", "OpenCodex uygulama menüsünde oturum açılışında başlatmanın işaretli olduğundan emin olun. Uygulama oturum açınca açılır ve proxy çalışmaya devam eder.", "Trong menu ứng dụng OpenCodex, hãy bảo đảm đã bật khởi động khi đăng nhập. Ứng dụng sẽ tự mở khi đăng nhập và proxy tiếp tục chạy.", "No menu do app OpenCodex, confirme que Iniciar no login está marcado. Assim o app abre ao entrar e o proxy continua rodando."],
stepOpen: ["Open", "열기", "Öffnen", "Ouvrir", "打开", "開啟", "Открыть", "開く", "Aç", "Mở", "Abrir"],
recentErrors: ["Recent errors", "최근 오류", "Letzte Fehler", "Erreurs récentes", "最近的错误", "最近的錯誤", "Недавние ошибки", "最近のエラー", "Son hatalar", "Lỗi gần đây", "Erros recentes"],
noErrors: ["No failed requests in the last 24 hours.", "최근 24시간 동안 실패한 요청이 없어요.", "Keine fehlgeschlagenen Anfragen in den letzten 24 Stunden.", "Aucune requête en échec ces dernières 24 h.", "过去 24 小时没有失败的请求。", "過去 24 小時沒有失敗的請求。", "За 24 часа ошибок нет.", "過去24時間に失敗したリクエストはありません。", "Son 24 saatte başarısız istek yok.", "Không có yêu cầu lỗi trong 24 giờ qua.", "Nenhuma falha nas últimas 24 horas."],
Expand Down
18 changes: 11 additions & 7 deletions gui/src/pages/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { IconRefresh } from "../icons";
import { Button } from "@/components/ui-v2/button";
import { cn } from "@/lib/utils";
import { recentFailures, type HomeLogRow } from "./home-failures";
import { startupChecklistDone } from "../startup-health-ui";
import { startupChecklistStep } from "../startup-health-ui";
import { useStartupChecklist } from "../hooks/useStartupChecklist";
import { TelemetryProfileCard } from "../components/telemetry-profile-card";

Expand Down Expand Up @@ -68,14 +68,18 @@ export default function Home({ apiBase, navigate, onReloadCodex, reloadingCodex
const requests7d = usage.data?.summary?.requests ?? 0;
const tokens7d = usage.data?.summary?.totalTokens ?? 0;
const uptime = health.data?.uptime ?? 0;
const autostart = startupChecklistStep(startup.data);

const steps = [
{ key: "provider", done: providerCount > 0, title: t("shellV2.stepProvider"), hint: t("shellV2.stepProviderHint"), go: () => navigate("providers") },
{ key: "request", done: requests7d > 0, title: t("shellV2.stepFirstRequest"), hint: t("shellV2.stepFirstRequestHint"), go: () => navigate("integrations") },
{ key: "provider", done: providerCount > 0, title: t("shellV2.stepProvider"), hint: t("shellV2.stepProviderHint"), showHint: false, go: () => navigate("providers") },
{ key: "request", done: requests7d > 0, title: t("shellV2.stepFirstRequest"), hint: t("shellV2.stepFirstRequestHint"), showHint: false, go: () => navigate("integrations") },
{
key: "autostart",
done: startup.data ? startupChecklistDone(startup.data) : undefined,
title: t("shellV2.stepAutostart"), hint: t("shellV2.stepAutostartHint"), go: () => navigate("startup"),
done: autostart.done,
title: t(autostart.titleKey), hint: t(autostart.hintKey),
// An unverified Desktop login still says where to check it.
showHint: Boolean(startup.data) && autostart.desktop && autostart.done === undefined,
go: () => autostart.subPath ? navigate("startup", autostart.subPath) : navigate("startup"),
},
];
const doneCount = steps.filter(step => step.done).length;
Expand Down Expand Up @@ -130,7 +134,7 @@ export default function Home({ apiBase, navigate, onReloadCodex, reloadingCodex
{steps.map((step, index) => {
const current = step.done === false && steps.findIndex(s => s.done === false) === index;
return (
<li key={step.key}>
<li key={step.key} data-step={step.key}>
<button type="button" onClick={step.go}
className={cn(
"group flex w-full cursor-pointer items-center gap-3 rounded-control p-2.5 text-left transition-colors hover:bg-raised",
Expand All @@ -142,7 +146,7 @@ export default function Home({ apiBase, navigate, onReloadCodex, reloadingCodex
)} aria-hidden>{step.done ? "✓" : index + 1}</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className={cn("text-[14px] font-medium", step.done && "text-fg-subtle line-through")}>{step.title}</span>
{step.done === false && <span className="text-[13px] text-fg-muted">{step.hint}</span>}
{(step.done === false || step.showHint) && <span className="text-[13px] text-fg-muted">{step.hint}</span>}
</span>
{step.done === false && <span className="text-[13px] font-medium text-highlight opacity-0 transition-opacity group-hover:opacity-100">{t("shellV2.stepOpen")} →</span>}
</button>
Expand Down
10 changes: 5 additions & 5 deletions gui/src/pages/SetupHub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { HubCard, type HubBadge } from "@/components/ui-v2/hub-card";
import { Button } from "@/components/ui-v2/button";
import { frame, sectionTitle } from "@/components/v2-connect/classes";
import { cn } from "@/lib/utils";
import { startupChecklistDone } from "../startup-health-ui";
import { startupChecklistStep } from "../startup-health-ui";
import { useStartupChecklist } from "../hooks/useStartupChecklist";

/*
Expand Down Expand Up @@ -53,7 +53,7 @@ export default function SetupHub({ apiBase, navigate }: SetupHubProps) {

const providers = config.data ? Object.keys(config.data.providers ?? {}).length > 0 : undefined;
const requested = usage.data ? (usage.data.summary?.requests ?? 0) > 0 : undefined;
const protectedStart = startup.data ? startupChecklistDone(startup.data) : undefined;
const autostart = startupChecklistStep(startup.data);

const steps: Step[] = [
{ key: "provider", title: t("shellV2.stepProvider"), hint: t("shellV2.stepProviderHint"),
Expand All @@ -62,8 +62,8 @@ export default function SetupHub({ apiBase, navigate }: SetupHubProps) {
done: requested, open: () => navigate("integrations") },
{ key: "request", title: t("shellV2.stepFirstRequest"), hint: t("shellV2.stepFirstRequestHint"),
done: requested, open: () => navigate("providers") },
{ key: "autostart", title: t("shellV2.stepAutostart"), hint: t("shellV2.stepAutostartHint"),
done: protectedStart, open: () => navigate("startup") },
{ key: "autostart", title: t(autostart.titleKey), hint: t(autostart.hintKey),
done: autostart.done, open: () => autostart.subPath ? navigate("startup", autostart.subPath) : navigate("startup") },
];
const doneCount = steps.filter(step => step.done === true).length;
const allDone = doneCount === steps.length;
Expand Down Expand Up @@ -117,7 +117,7 @@ function SetupRow({ step, index, current, openLabel, doneLabel, todoLabel }: {
}) {
const done = step.done === true;
return (
<li className={cn("flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3.5", current && "bg-surface")}>
<li data-step={step.key} className={cn("flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3.5", current && "bg-surface")}>
<span
className={cn(
"grid size-7 shrink-0 place-items-center rounded-full text-[12px] font-semibold tabular-nums [&_svg]:size-3.5",
Expand Down
2 changes: 2 additions & 0 deletions gui/src/pages/Startup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Button } from "@/components/ui-v2/button";
import { cn } from "@/lib/utils";
import { Banner, CopyButton, PageHeader } from "../components/v2-startup/primitives";
import { startupPresentation } from "../startup-health-ui";
import { useStartupDesktopFocus } from "./startup-desktop-focus";
import {
StartupHeroSection,
StartupOptionsSection,
Expand Down Expand Up @@ -253,6 +254,7 @@ export default function Startup({ apiBase, machineApiBase = apiBase, connected =
const loading = loadState.refreshing;
const failed = Boolean(data?.diagnosticStale) || loadState.showError;
const unverified = data !== null && startupPresentation(data, failed) === "unverified";
useStartupDesktopFocus(Boolean(data?.desktop));

useEffect(() => {
if (!data?.diagnosticStale) return;
Expand Down
39 changes: 39 additions & 0 deletions gui/src/pages/startup-desktop-focus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useEffect, useRef, useState } from "react";
import { canonicalHashPath } from "../app-routing";

/** Getting started links here when OpenCodex Desktop keeps the proxy running. */
export const STARTUP_DESKTOP_HASH = "startup/desktop";

function atDesktopHash(): boolean {
return typeof window !== "undefined" && canonicalHashPath(window.location.hash) === STARTUP_DESKTOP_HASH;
}

/**
* Bring the Desktop card into view each time the page is reached through `#startup/desktop`.
*
* App mounts Startup without the sub-path, and navigating within the page changes only the hash,
* so arrivals are counted from `hashchange`. Each arrival is spent once, after the card renders,
* so later polls and stale refreshes never move focus. No card means nothing happens, and the
* hash is left as it is.
*/
export function useStartupDesktopFocus(cardRendered: boolean): void {
const [arrival, setArrival] = useState(() => (atDesktopHash() ? 1 : 0));
const handled = useRef(0);

useEffect(() => {
const onHash = () => { if (atDesktopHash()) setArrival(count => count + 1); };
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
}, []);

useEffect(() => {
if (arrival === 0 || handled.current === arrival || !cardRendered) return;
const card = document.querySelector<HTMLElement>('[data-option="desktop"]');
if (!card) return;
handled.current = arrival;
const reduceMotion = typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
card.scrollIntoView?.({ behavior: reduceMotion ? "auto" : "smooth", block: "start" });
if (!card.hasAttribute("tabindex")) card.tabIndex = -1;
card.focus({ preventScroll: true });
}, [arrival, cardRendered]);
}
4 changes: 3 additions & 1 deletion gui/src/pages/startup-sections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,9 @@ export function StartupOptionsSection({
<OptionCard option="desktop" icon={hugeIcon(LaptopIcon)} title={t("startup.protection.desktop")}
badge={{ tone: desktopOk ? "ok" : startupPresentation(data, failed) === "unverified" ? "neutral" : "warn", label: t(startupPresentation(data, failed) === "unverified" ? "startup.loginUnverifiedTitle" : desktopBadgeKey(data.desktop, failed)) }}>
{desktopOk ? <p className="m-0">{t("startup.desktopHint")}</p>
: !failed && data.desktop.running ? <p className="m-0">{t("startup.desktopRunning")}</p> : null}
: failed ? null
: data.desktop.running ? <p className="m-0">{t("startup.desktopRunning")}</p>
: <p className="m-0">{t("startup.desktopReopenRecovery")}</p>}
</OptionCard>
)}
<OptionCard option="service" icon={hugeIcon(ServerStack01Icon)} title={t("startup.service")}
Expand Down
40 changes: 38 additions & 2 deletions gui/src/startup-health-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ export interface StartupRiskDetail {
shimCoverage: "full" | "cli-only" | "none";
}

export function desktopManagesStartup(health: Pick<StartupRiskDetail, "desktop">): boolean {
return health.desktop !== undefined && (health.desktop.owned || health.desktop.supervisor !== undefined);
export function desktopManagesStartup(health: { desktop?: { owned?: boolean; supervisor?: unknown } }): boolean {
return health.desktop !== undefined && (health.desktop.owned === true || health.desktop.supervisor !== undefined);
}

type DesktopPresentation = { owned?: boolean; supervisor?: unknown; loginState?: unknown; running?: boolean; viable?: boolean; supervisionUnknown?: boolean };
Expand Down Expand Up @@ -51,6 +51,42 @@ export function startupChecklistDone(data: {
return (data.protection !== undefined && data.protection !== "none") || Boolean(data.serviceInstalled || data.shimInstalled);
}

export type StartupChecklistBody = Parameters<typeof startupChecklistDone>[0] & { platform?: string };

export interface StartupChecklistStep {
/** The reading names an OpenCodex Desktop app that runs this proxy. */
desktop: boolean;
/** undefined while unknown: neither done nor to do. */
done: boolean | undefined;
titleKey: TKey;
hintKey: TKey;
/** Startup sub-view the step opens. */
subPath?: "desktop";
}

/**
* The Getting started "keep it running" step, shared by Home and the Set up hub.
*
* Under Desktop the restart protection is the app's Start at Login, so a service or shim never
* completes the step. Only fresh evidence decides it: viable supervision is done; a login item
* verified off is to do; so is a supervisor confirmed missing on macOS, whatever the Codex
* routing. Unreadable evidence carries `supervisionUnknown`, and Windows and Linux never verify
* an absent login item, so those readings stay unknown rather than "to do" (do not simplify the
* macOS arm to `!viable`). A browser reading keeps `startupChecklistDone`.
*/
export function startupChecklistStep(data: StartupChecklistBody | null | undefined): StartupChecklistStep {
if (!data || !desktopManagesStartup(data)) {
return { desktop: false, done: data ? startupChecklistDone(data) : undefined, titleKey: "shellV2.stepAutostart", hintKey: "shellV2.stepAutostartHint" };
}
const desktop = data.desktop!;
const done = data.diagnosticStale === true ? undefined
: desktop.viable === true ? true
: desktop.loginState === "disabled" ? false
: data.platform === "darwin" && desktop.running === false && desktop.viable === false && desktop.supervisionUnknown !== true ? false
: undefined;
return { desktop: true, done, titleKey: "shellV2.stepAutostartDesktop", hintKey: "shellV2.stepAutostartDesktopHint", subPath: "desktop" };
}

/**
* Badge for the Desktop app row. A running app whose Start at Login could not be verified is not
* "installed but unhealthy": the proxy is fine and only restart protection is missing (#6963).
Expand Down
Loading
Loading