From 167dc591cd3e47600ddd844b379094e4b661e2f3 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 10 Nov 2026 20:41:53 +0900 Subject: [PATCH 1/3] fix(gui): match the Getting started startup step to OpenCodex Desktop Under OpenCodex Desktop the proxy survives a restart through the app's Start at Login, but Home and the Set up hub still showed the service step ("Start automatically"), credited it from a service or shim, and opened the Startup page top. Both pages now share startupChecklistStep. A Desktop reading gets a Start at Login step that only fresh viable supervision completes, verified-off login or a confirmed missing macOS supervisor marks to do, and anything unverifiable (stale, Windows, Linux without evidence) leaves unknown. The step opens #startup/desktop, which focuses the Desktop card once per arrival. Browser readings keep the existing step, completion and destination. --- .../000_plan.md | 109 ++++++++ .../src/content/docs/guides/desktop-app.md | 5 + gui/src/app-routing.ts | 1 + gui/src/hooks/useStartupChecklist.ts | 4 +- gui/src/i18n/shell-v2-copy.ts | 2 + gui/src/pages/Home.tsx | 18 +- gui/src/pages/SetupHub.tsx | 10 +- gui/src/pages/Startup.tsx | 2 + gui/src/pages/startup-desktop-focus.ts | 39 +++ gui/src/pages/startup-sections.tsx | 4 +- gui/src/startup-health-ui.ts | 40 ++- gui/tests/startup-checklist-desktop.test.tsx | 247 ++++++++++++++++++ gui/tests/startup-checklist-refresh.test.tsx | 33 ++- structure/gui-and-management-api.md | 2 +- 14 files changed, 490 insertions(+), 26 deletions(-) create mode 100644 devlog/_plan/261110_desktop_autostart_checklist/000_plan.md create mode 100644 gui/src/pages/startup-desktop-focus.ts create mode 100644 gui/tests/startup-checklist-desktop.test.tsx diff --git a/devlog/_plan/261110_desktop_autostart_checklist/000_plan.md b/devlog/_plan/261110_desktop_autostart_checklist/000_plan.md new file mode 100644 index 00000000000..ed6488b1ff1 --- /dev/null +++ b/devlog/_plan/261110_desktop_autostart_checklist/000_plan.md @@ -0,0 +1,109 @@ +# Desktop startup checklist + +The Home and SetupHub checklists currently describe CLI startup even when Desktop supervises the proxy. This unit will give both surfaces the same Desktop-specific step and retain the existing browser-service step. The login control remains in the app's existing tray menu; the Startup page describes its state and directs the user there. + +- Archetype: satisfy specification. Trigger: owner report of a perpetually incomplete desktop checklist. +- Goal: show correct login-at-startup copy, completion and destination in both screens; preserve non-Desktop behavior. +- Non-goals: change runtime startup, registration, authentication, dependencies or CI policy; no lane=all dispatch. +- Stop: merged PR with exact-head independent review and the user's requested evidence; report unmet gates without calling them passed. +- Artifact: this unit and focused test receipts. Outcome: DONE only after merge, otherwise report actual blocker or unfinished work. +- Escalation: unexpected security or compatibility changes, missing authorization or unavailable required independent review. +- Scope: this checkout, repository PR and explicitly requested screenshot assets; user specified no time or token limit. + +## Evidence and candidate decisions (pending architect consultation) + +- `gui/src/pages/Home.tsx` and `gui/src/pages/SetupHub.tsx` both call `startupChecklistDone`, but each uses browser-only copy and Startup navigation. +- `src/service/desktop-startup.ts` derives `viable` only from desktop ownership/supervision, enabled login, and running supervision; a login registration alone does not establish proxy supervision. +- `gui/src/pages/startup-sections.tsx` shows desktop status and existing guidance directs the user to the OpenCodex menu. The GUI itself has no established equivalent toggle in that section. +- `gui/tests/startup-checklist-refresh.test.tsx` exercises both consumers and stale refresh, including synthetic desktop data that needs careful reconciliation with the new contract. + +## Candidate file map and proof + +- MODIFY `gui/src/startup-health-ui.ts` or a small sibling: one shared derived checklist definition for desktop detection, tri-state completion, copy keys and destination. Treat stale or unconfirmed diagnostics as unknown; do not allow service/shim evidence to certify Desktop login. Preserve existing browser path exactly. +- MODIFY Home and SetupHub to use that shared definition and navigate to the existing Startup desktop guidance. Determine how to reach the relevant section from the real routing implementation before finalizing. +- MODIFY `gui/src/i18n/shell-v2-copy.ts`: two Desktop strings for all 11 locales, consistent with existing Start at Login terminology. +- MODIFY `gui/tests/startup-checklist-refresh.test.tsx` or add a GUI test alongside it: both pages, desktop viable, disabled, unverified and stale, browser service/shim states, labels and click destination. Run failing tests against unchanged dev before implementation and preserve output. +- Assess `docs-site` and `structure` ownership, updating the applicable source of truth if behavior documentation needs it. +- Run focused tests, GUI suite, `bun run lint:gui`, `bun run typecheck`, `bun run build:gui`, GUI i18n lint, locale parity, file-size ratchet and test-layout guards. Capture real rendered desktop on/off and browser views and attach as PR assets outside the change branch. +- Create templated PR to dev, record maintainer judgment, obtain fresh independent final-head review and inspect actual CI failures at final head; merge per owner instruction without waiting for pending CI or CodeQL. If dev conflicts, merge dev and repeat review. + +Verifier baseline and detailed boundary cases, architect decisions and audit dispositions are pending. + +## Baseline evidence + +On the unchanged source at `05d517e96038cb84fa63e2b2aa4f236385d01261`, after `cd gui && bun install --frozen-lockfile`, `bun test tests/startup-checklist-refresh.test.tsx tests/locale-parity.test.ts` exited 0: 10 pass, 0 fail, 140 assertions. This establishes the existing refresh and locale checks only; it is not the requested red-first regression. The first attempt failed to load React because GUI dependencies were absent; the lockfile install resolved that environment issue. + +`deriveDesktopStartup` computes `viable` from ownership or supervisor, `loginEnabled`, and `running`, so enabled login alone is insufficient when supervision is known to have failed. `diagnosticStale`, unknown login and unknown supervision require distinct tests; a stale service/shim reading must not silently count as Desktop login. The Startup page's current desktop card has no toggle; tray menu guidance is the existing action path. Confirm the route and visible guidance in rendered tests. + +The Startup route currently mounts `` from `gui/src/App.tsx` without a subpath prop. The desktop option is rendered by `gui/src/pages/startup-sections.tsx` as an `OptionCard` with `data-option="desktop"`; its surrounding Startup status supplies the menu guidance when appropriate. A plan that claims a direct section destination must implement and test actual focus/scroll or equivalent navigation, including asynchronous health loading, rather than merely pass an ignored subpath. If using the existing Startup page as the destination, verify that the correct desktop status and menu guidance are visible in the targeted scenario. + +Platform constraint: `docs-site/src/content/docs/guides/desktop-app.md:173-177` says Windows can identify supervision but cannot verify Start at Login, so it earns no startup-protection credit. A Windows Desktop fixture must remain unknown and must not infer login success from running supervision; the PR's Windows/macOS judgment must name that limitation. + +Baseline guard: after root `bun install --frozen-lockfile`, `bun test tests/ci-workflows/file-size-ratchet.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts` exited 0 with 38 pass, 0 fail. These guards read the repository and test inventory; rerun them against the completed change if affected. + +## Required regression matrix + +| Environment and evidence | Completion | Expected copy and route | +| --- | --- | --- | +| Desktop owned/supervised, fresh, viable | true | Desktop login copy; Startup desktop guidance | +| Desktop owned/supervised, fresh, login disabled | false | Desktop login copy; Startup desktop guidance | +| Desktop, login unknown or supervision uncertain | undefined | Desktop login copy; Startup desktop guidance | +| Desktop, stale diagnostics | undefined | Desktop login copy; Startup desktop guidance | +| macOS Desktop, login enabled but supervision fails (any routing kind) | false | Desktop login copy; Startup desktop card with reopen guidance | +| Linux Desktop, login enabled or missing, app closed | undefined | Desktop login copy and hint; Startup desktop guidance | +| Windows Desktop with login unverified | undefined | Desktop login copy; Startup desktop guidance | +| Non-Desktop, installed service/shim or protection | unchanged existing boolean | Existing autostart copy and Startup route | +| Non-Desktop without protection | false | Existing autostart copy and Startup route | +| No payload | undefined | Do not fabricate a completed state | + +Exercise both Home and SetupHub independently, including click destination. Keep existing stale-refresh and target-change tests valid with realistic payloads; only change their assertions to reflect intentional copy/state contract changes, not to hide failures. + +Rendered Home hides the entire checklist when all three steps are complete (`Home.tsx` allDone branch); tests and screenshots asserting the third step in the enabled case must leave another step incomplete, or separately assert the all-done summary. SetupHub retains its rows and uses absent badges for unknown. Verify both without changing this existing behavior. + +Verified architect observation: `gui/src/app-routing.ts:177-191` does not allow a Startup subpath in `hashBelongsToPage`; `gui/src/use-app-route-state.ts:55-63` documents that such a path is normalized away unless explicitly accepted. A deep link therefore also needs a routing contract test, refresh/back-forward behavior, and a Startup-side scroll/focus test after diagnostics render. + +## Executable plan (diff level, reconciled after architect reflection) + +Decisions: + +- D1 Desktop detection comes from the health payload: `desktopManagesStartup(data)` (`gui/src/startup-health-ui.ts:11`). Neither `startupDesktopContext` (it requires opencodex-local routing) nor the user agent decides it, because a Desktop window can show a connected target whose proxy the app does not manage; that target keeps the browser step. +- D2 Desktop completion, composed from existing predicates, in order: no payload or `diagnosticStale === true` gives undefined; `desktop.viable === true` gives true; `desktop.loginState === "disabled"`, or a confirmed supervision failure on `platform === "darwin"` (the only platform whose login evidence is verified), gives false. The failure test is routing independent: `desktop.running === false && desktop.viable === false && desktop.supervisionUnknown !== true`, because `desktopSupervisionFailed` also requires opencodex-local routing and would leave a Desktop proxy with native routing undefined forever (audit blocker 1); only proven-absent macOS shapes reach it (no LaunchAgent with a matching install id, `src/service/desktop-startup.ts:207-212`, or a confirmed missing supervisor); unreadable evidence carries `supervisionUnknown: true` and stays undefined, so the arm must never be simplified to `!viable`. The ownership-only placeholder (`src/server/startup-health-cache.ts:104-123`) is marked `diagnosticStale: true` and is caught first. A missing `platform` counts as not darwin; anything else gives undefined. On Windows an owned claim with a mismatched install id or unreadable evidence reports `running: false` without `supervisionUnknown` (`src/service/desktop-startup.ts:388-405`), so the failure arm must not run there. Linux never reports `loginState: "disabled"` for a missing autostart entry (`src/service/desktop-startup.ts:289-291`), so Linux Desktop stays undefined unless viable; making it countable needs a backend change outside this lane. `viable` already folds login and supervision (`src/service/desktop-startup.ts:30-33`), so there is no separate enabled-and-running arm. Windows never verifies login (`src/service/desktop-startup.ts:377-405`), so it stays undefined as the owner asked; this matches today's unverified result. Service or shim evidence never completes the Desktop step, including a Desktop proxy with native or custom routing. +- D3 Browser completion delegates to `startupChecklistDone` unchanged; copy and `navigate("startup")` unchanged. The checklist body type gains `platform?: string` and the hook derives its body type from `startupChecklistStep`. +- D4 No in-place toggle. Start at Login is changed only from the app's tray menu (`desktop/src-tauri/src/tray_login.rs`); the loopback dashboard has no Tauri permission to change it (`desktop/src-tauri/capabilities/dashboard-titlebar.json`), and a dashboard toggle would duplicate `login_registration::judge` on a remote origin. The destination is the Startup page's Desktop card. +- D5 Copy: two new rows authored per locale as action phrases built from that locale's existing Start at Login term (the bare term appears in `startup.desktopRecovery`/`startup.loginUnverifiedAction` prose; badge keys are not reused). Title, e.g. en "Turn on Start at Login", ko "로그인 시 시작 켜기". Hint names the OpenCodex app menu, says "make sure it is checked", and says the app then opens at login and keeps the proxy running; it is machine neutral and promises no startup-safety credit. +- D6 Unknown Desktop presentation: completion stays undefined, but Home shows the Desktop hint when `startup.data` is loaded, `step.desktop === true` and `step.done === undefined` (Home otherwise shows hints only for `false`), so a Windows row explains where to look. SetupHub already shows every hint. The denominator is unchanged. +- D8 Destination content: when the Desktop card is neither viable nor running and the reading is fresh, the card body renders the existing `startup.desktopReopenRecovery` ("Reopen OpenCodex and check Start at Login.") instead of nothing (`gui/src/pages/startup-sections.tsx:162-164`), so the focused card carries its action. It never renders while `failed` is true. Both pages mark the step row with `data-step="autostart"` so tests select it without depending on copy. +- D7 Deep link `#startup/desktop`: `hashBelongsToPage` accepts it. A Startup-side hook listens to `hashchange` and to the Desktop card's render; on each arrival at that hash it scrolls the card into view (smooth unless reduced motion) and focuses it with `preventScroll`, setting `tabIndex = -1` imperatively. It fires once per arrival, ignores polls and stale refreshes, does nothing without a Desktop card, and never rewrites history. This is the v2 router only; the classic dashboard has no Home or SetupHub checklist. + +Files: + +- MODIFY `gui/src/startup-health-ui.ts`: add `startupChecklistStep(data)` returning `{ desktop, done, titleKey, hintKey, subPath }` beside `startupChecklistDone`. +- MODIFY `gui/src/pages/Home.tsx`, `gui/src/pages/SetupHub.tsx`: consume it; `navigate("startup", step.subPath)`; Home D6 hint. +- MODIFY `gui/src/app-routing.ts`: accept `startup/desktop`. +- ADD `gui/src/pages/startup-desktop-focus.ts`; wire from `gui/src/pages/Startup.tsx`. +- MODIFY `gui/src/i18n/shell-v2-copy.ts`: two rows, 11 columns. +- MODIFY `gui/src/pages/startup-sections.tsx`: D8. +- ADD `gui/tests/startup-checklist-desktop.test.tsx`: predicate table, both pages with realistic Desktop payloads (viable, disabled, unknown, stale, macOS supervision failed with opencodex-local routing gives false, the same evidence with native routing gives undefined, Windows supervisor-only and Windows owned with running false give undefined, Linux without autostart gives undefined, Desktop plus shim/service is never true), browser payloads, click destinations, routing acceptance, Startup focus (cold load after data, hashchange while on Startup, no card no-op, poll does not refocus). No layout registration: `gui/tests` is outside the root inventory. +- MODIFY `gui/tests/startup-checklist-refresh.test.tsx`: split fixtures into browser and Desktop sets; change assertions only where the contract changed. +- MODIFY `structure/gui-and-management-api.md` Startup safety paragraph and `docs-site/src/content/docs/guides/desktop-app.md` (one English sentence). + +Acceptance: + +- A1 New tests fail on unchanged source and pass after the change; red output recorded below. +- A2 Browser payloads keep the same title, hint, completion and destination. +- A3 `cd gui && bun ../scripts/test-with-pinned-bun.ts --isolate tests/startup-checklist-desktop.test.tsx tests/startup-checklist-refresh.test.tsx tests/startup-minimal.test.tsx tests/startup-revisit-cache.test.tsx tests/startup-install-result-reconciliation.test.tsx tests/i18n-locales.test.ts tests/locale-parity.test.ts tests/dashboard-tabs.test.ts tests/providers-hash-history.test.tsx tests/codex-set-shell.test.tsx`; `bun run test -- tests/gui/startup-health-ui.test.ts tests/gui/routing-intelligence-ui.test.ts tests/ci-workflows/file-size-ratchet.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts`; `bun run lint:gui`; `bun run typecheck` (root; GUI types ride on `bun run build:gui`'s `tsc -b`); `bun run build:gui`; `bun run structure:check`; `bun run privacy:scan`; `cd docs-site && bun install --frozen-lockfile && bun run build`. All exit 0. Localized desktop-app guides stay untouched; the English sentence adds guidance without contradicting them. +- A4 Screenshots of Desktop on, Desktop off and browser states attached through `pr-assets`. + +## Architect consultation (hyds/deepseek-v4.1-flash-free) + +Proposal (task desktop-checklist-architect-hyds-2): approve-with-changes. Accepted: compose from existing predicates, keep `desktopManagesStartup`, hashchange-driven one-shot focus, menu-named machine-neutral copy, fixture split, structure/docs updates, no toggle. Windows: completion stays undefined per owner; presentation fixed by D6. + +Reflection (same thread, run 2): not-ready because D2 and the file list contradicted the dispositions, D5 pointed at badge keys, and the Windows row had no explanation. All three are reconciled above, plus `--isolate` and the GUI typecheck note in A3. + +Reflection (run 3): not-ready because the supervision-failure arm would return false on Windows and Linux could never return false. Adopted as proposed: failure arm gated on darwin, Windows and Linux stay undefined with the limitation stated in the PR, matrix rows carry the routing kind, and D8 gives the focused card its action. No other objections remained on D1, D3-D7, files or acceptance. + +## Audit (A) + +Reviewer hyds/deepseek-v4.1-flash-free (task desktop-checklist-audit-a1): GO-WITH-FIXES (blockers=2). Folded: (1) routing-independent supervision-failure arm in D2; (2) A3 uses the pinned runner. Also folded: platform in the body type, named consumer and routing tests, `data-step` selector, matrix rows for macOS and Linux, docs-site build, v2-only note, D6 guard, browser arm delegates to `startupChecklistDone`, D8 hidden while failed. A mistral fallback audit was cancelled after the primary finished. The D2 change goes to the architect for reflection. + +Architect recheck (run 4): ALIGNED with the routing-independent arm; its guard notes are folded into D2. Main judgment: near-pass. Both High blockers folded, no residual blockers. diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index 9d6ce126ebc..e16ad51ab04 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -136,6 +136,11 @@ 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. On +Windows the registration is not verified, so the step stays unmarked; 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 diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index 7be30b4c895..7ff34afc695 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -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" && ( diff --git a/gui/src/hooks/useStartupChecklist.ts b/gui/src/hooks/useStartupChecklist.ts index 50a36397c14..d4e852f7ae6 100644 --- a/gui/src/hooks/useStartupChecklist.ts +++ b/gui/src/hooks/useStartupChecklist.ts @@ -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[0]; +type StartupBody = StartupChecklistBody; export function useStartupChecklist(apiBase: string) { const key = `home-startup:${apiBase}`; diff --git a/gui/src/i18n/shell-v2-copy.ts b/gui/src/i18n/shell-v2-copy.ts index bf357c83a01..9350a33ae44 100644 --- a/gui/src/i18n/shell-v2-copy.ts +++ b/gui/src/i18n/shell-v2-copy.ts @@ -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 „Start beim Anmelden“ 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."], diff --git a/gui/src/pages/Home.tsx b/gui/src/pages/Home.tsx index b6870519b8f..e2d6fd0b2d4 100644 --- a/gui/src/pages/Home.tsx +++ b/gui/src/pages/Home.tsx @@ -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"; @@ -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; @@ -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 ( -
  • +
  • diff --git a/gui/src/pages/SetupHub.tsx b/gui/src/pages/SetupHub.tsx index f01624d2160..15b7616b649 100644 --- a/gui/src/pages/SetupHub.tsx +++ b/gui/src/pages/SetupHub.tsx @@ -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"; /* @@ -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"), @@ -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; @@ -117,7 +117,7 @@ function SetupRow({ step, index, current, openLabel, doneLabel, todoLabel }: { }) { const done = step.done === true; return ( -
  • +
  • { if (!data?.diagnosticStale) return; diff --git a/gui/src/pages/startup-desktop-focus.ts b/gui/src/pages/startup-desktop-focus.ts new file mode 100644 index 00000000000..2bc225851bd --- /dev/null +++ b/gui/src/pages/startup-desktop-focus.ts @@ -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('[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]); +} diff --git a/gui/src/pages/startup-sections.tsx b/gui/src/pages/startup-sections.tsx index ad9625449b3..785f0465383 100644 --- a/gui/src/pages/startup-sections.tsx +++ b/gui/src/pages/startup-sections.tsx @@ -160,7 +160,9 @@ export function StartupOptionsSection({ {desktopOk ?

    {t("startup.desktopHint")}

    - : !failed && data.desktop.running ?

    {t("startup.desktopRunning")}

    : null} + : failed ? null + : data.desktop.running ?

    {t("startup.desktopRunning")}

    + :

    {t("startup.desktopReopenRecovery")}

    }
    )} ): 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 }; @@ -51,6 +51,42 @@ export function startupChecklistDone(data: { return (data.protection !== undefined && data.protection !== "none") || Boolean(data.serviceInstalled || data.shimInstalled); } +export type StartupChecklistBody = Parameters[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). diff --git a/gui/tests/startup-checklist-desktop.test.tsx b/gui/tests/startup-checklist-desktop.test.tsx new file mode 100644 index 00000000000..c79b5b7fbca --- /dev/null +++ b/gui/tests/startup-checklist-desktop.test.tsx @@ -0,0 +1,247 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, type ReactNode } from "react"; +import type { Root } from "react-dom/client"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { LanguageProvider } from "../src/i18n/provider"; +import Home from "../src/pages/Home"; +import SetupHub from "../src/pages/SetupHub"; +import Startup from "../src/pages/Startup"; +import * as routing from "../src/app-routing"; +import * as ui from "../src/startup-health-ui"; + +/* + * Getting started step 3 under OpenCodex Desktop (devlog/_plan/261110_desktop_autostart_checklist). + * The app keeps the proxy alive through Start at Login, so the step's copy, completion and + * destination follow the Desktop reading; every browser reading keeps the original step. + */ + +const names = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let saved: Record; +let win: Window; +let root: Root | undefined; +let health: unknown; + +function install(url: string) { + win = new Window({ url }); + win.localStorage.setItem("ocx-lang", "en"); + for (const name of names.filter(value => value !== "fetch")) { + Object.defineProperty(globalThis, name, { configurable: true, value: name === "IS_REACT_ACT_ENVIRONMENT" ? true : Reflect.get(win, name) }); + } +} + +beforeEach(() => { + saved = Object.fromEntries(names.map(name => [name, Object.getOwnPropertyDescriptor(globalThis, name)])); + install("http://localhost/#home"); + clearClientResourceStoresForTests(); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const path = new URL(String(input), "http://localhost/").pathname; + if (path === "/api/startup-health") return Response.json(typeof health === "function" ? (health as () => unknown)() : health); + if (path === "/api/config") return Response.json({ providers: {} }); + if (path === "/api/logs") return Response.json({ logs: [] }); + if (path === "/api/settings") return Response.json({ codexAutoStart: true, codexRuntime: { version: "x" } }); + return Response.json({}); + }) as typeof fetch; +}); + +afterEach(async () => { + if (root) await act(async () => { root!.unmount(); }); + root = undefined; + clearClientResourceStoresForTests(); + win.close(); + for (const name of names) { + if (saved[name]) Object.defineProperty(globalThis, name, saved[name]!); + else Reflect.deleteProperty(globalThis, name); + } +}); + +const commands = { installService: "ocx service install", repairService: "ocx service repair", installShim: "ocx shim install", restoreNative: "ocx restore" }; +function reading(overrides: Record) { + return { + status: "at-risk", routingKind: "opencodex-local", routingInjected: true, localRoutingDependency: true, + autostartEnabled: false, rebootSafe: false, protection: "none", + serviceInstalled: false, serviceViable: false, serviceEnabled: false, serviceRunning: false, + serviceStale: false, serviceConflict: false, serviceSupported: true, + shimInstalled: false, shimHealthy: false, shimCoverage: "none", platform: "darwin", + recommendedCommand: null, diagnosticStale: false, commands, + ...overrides, + }; +} +const supervisor = { supervisorPid: 41, runtimePid: 42, app: "/Applications/OpenCodex.app" }; +const fixtures = { + macOn: reading({ status: "protected", rebootSafe: true, protection: "desktop", desktop: { owned: true, loginEnabled: true, loginState: "enabled", running: true, viable: true } }), + macOff: reading({ desktop: { owned: true, loginEnabled: false, loginState: "disabled", running: true, viable: false } }), + macUnknown: reading({ desktop: { owned: true, loginEnabled: false, loginState: "unknown", running: true, viable: false } }), + macStale: reading({ diagnosticStale: true, desktop: { owned: true, loginEnabled: true, loginState: "enabled", running: true, viable: true } }), + macSupervisionUnknown: reading({ desktop: { owned: true, loginEnabled: true, loginState: "enabled", running: false, viable: false, supervisionUnknown: true } }), + macStoppedNative: reading({ status: "native", routingKind: "native", localRoutingDependency: false, desktop: { owned: true, loginEnabled: true, loginState: "enabled", running: false, viable: false } }), + macStoppedLocal: reading({ desktop: { owned: true, loginEnabled: true, loginState: "enabled", running: false, viable: false } }), + macOffWithStarters: reading({ protection: "shim", shimInstalled: true, shimHealthy: true, serviceInstalled: true, desktop: { owned: true, loginEnabled: false, loginState: "disabled", running: true, viable: false } }), + winSupervised: reading({ platform: "win32", desktop: { owned: false, loginEnabled: false, loginState: "unknown", running: true, viable: false, supervisor } }), + winOwnedStopped: reading({ platform: "win32", desktop: { owned: true, loginEnabled: false, loginState: "unknown", running: false, viable: false } }), + linuxClosed: reading({ platform: "linux", desktop: { owned: true, loginEnabled: true, loginState: "enabled", running: false, viable: false } }), + browserService: reading({ status: "protected", rebootSafe: true, protection: "service", serviceInstalled: true, serviceViable: true }), + browserShim: reading({ protection: "shim", shimInstalled: true, shimHealthy: true }), + browserNone: reading({}), +} as const; + +const DESKTOP_TITLE = "Turn on Start at Login"; +const DESKTOP_HINT = "In the OpenCodex app menu, make sure Start at Login is checked."; +const BROWSER_TITLE = "Start automatically"; +const BROWSER_HINT = "Keep opencodex running after a restart"; + +const cases: Array<[keyof typeof fixtures, boolean | undefined, boolean]> = [ + ["macOn", true, true], + ["macOff", false, true], + ["macUnknown", undefined, true], + ["macStale", undefined, true], + ["macSupervisionUnknown", undefined, true], + ["macStoppedNative", false, true], + ["macStoppedLocal", false, true], + ["macOffWithStarters", false, true], + ["winSupervised", undefined, true], + ["winOwnedStopped", undefined, true], + ["linuxClosed", undefined, true], + ["browserService", true, false], + ["browserShim", true, false], + ["browserNone", false, false], +]; + +test("one shared step decides Desktop copy, completion and destination", () => { + const step = (ui as Record).startupChecklistStep as ((data: unknown) => { desktop: boolean; done: boolean | undefined; titleKey: string; hintKey: string; subPath?: string }) | undefined; + expect(typeof step).toBe("function"); + for (const [name, done, desktop] of cases) { + const result = step!(fixtures[name]); + expect({ name, done: result.done, desktop: result.desktop }).toEqual({ name, done, desktop }); + expect(result.titleKey).toBe(desktop ? "shellV2.stepAutostartDesktop" : "shellV2.stepAutostart"); + expect(result.hintKey).toBe(desktop ? "shellV2.stepAutostartDesktopHint" : "shellV2.stepAutostartHint"); + expect(result.subPath).toBe(desktop ? "desktop" : undefined); + if (!desktop) expect(result.done).toBe(ui.startupChecklistDone(fixtures[name])); + } + expect(step!(undefined).done).toBeUndefined(); + expect(step!(null).done).toBeUndefined(); +}); + +async function mount(node: ReactNode): Promise { + const host = document.createElement("div"); + document.body.append(host); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render({node}); + await new Promise(resolve => setTimeout(resolve, 30)); + }); + return host; +} + +function autostartRow(host: HTMLElement): HTMLElement { + const row = [...host.querySelectorAll("li")].find(li => li.dataset.step === "autostart" + || li.textContent?.includes(BROWSER_TITLE) || li.textContent?.includes(DESKTOP_TITLE)); + if (!row) throw new Error("no autostart row"); + return row as HTMLElement; +} + +for (const [name, done, desktop] of cases.filter(([name]) => ["macOn", "macOff", "macUnknown", "winSupervised", "browserService", "browserNone"].includes(name))) { + test("Home shows the " + name + " step with its copy, state and destination", async () => { + health = fixtures[name]; + const calls: unknown[][] = []; + const host = await mount( { calls.push(args); }} onReloadCodex={() => {}} reloadingCodex={false} />); + const row = autostartRow(host); + expect(row.dataset.step).toBe("autostart"); + expect(row.textContent).toContain(desktop ? DESKTOP_TITLE : BROWSER_TITLE); + expect(row.textContent?.includes("✓")).toBe(done === true); + // Unknown Desktop login still says where to look; the browser step keeps its hint for "to do" only. + expect(row.textContent?.includes(desktop ? DESKTOP_HINT : BROWSER_HINT)).toBe(desktop ? done !== true : done === false); + await act(async () => { row.querySelector("button")!.click(); }); + expect(calls).toEqual([desktop ? ["startup", "desktop"] : ["startup"]]); + }); + + test("SetupHub shows the " + name + " step with its copy, state and destination", async () => { + health = fixtures[name]; + const calls: unknown[][] = []; + const host = await mount( { calls.push(args); }} />); + const row = autostartRow(host); + expect(row.dataset.step).toBe("autostart"); + expect(row.textContent).toContain(desktop ? DESKTOP_TITLE : BROWSER_TITLE); + expect(row.textContent).toContain(desktop ? DESKTOP_HINT : BROWSER_HINT); + expect(row.textContent?.includes("Done")).toBe(done === true); + expect(row.textContent?.includes("To do")).toBe(done === false); + await act(async () => { row.querySelector("button")!.click(); }); + expect(calls).toEqual([desktop ? ["startup", "desktop"] : ["startup"]]); + }); +} + +test("Home never shows a Desktop hint before the reading arrives", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => String(input).includes("/api/startup-health") + ? new Promise(() => {}) : Response.json({ providers: {}, logs: [] })) as typeof fetch; + const host = await mount( {}} onReloadCodex={() => {}} reloadingCodex={false} />); + const row = autostartRow(host); + expect(row.textContent).not.toContain(DESKTOP_HINT); + expect(row.textContent).not.toContain("✓"); +}); + +test("the router keeps #startup/desktop on the Startup page", () => { + expect(routing.hashBelongsToPage("startup/desktop", "startup")).toBe(true); + expect(routing.resolveAppHashChange("startup/desktop")).toEqual({ page: "startup", replaceTo: null }); + expect(routing.hashBelongsToPage("startup/other", "startup")).toBe(false); +}); + +async function mountStartup(url: string): Promise { + win.close(); + install(url); + return mount(); +} + +function desktopCard(host: HTMLElement): HTMLElement { + return host.querySelector('[data-option="desktop"]')!; +} + +test("arriving at #startup/desktop focuses the Desktop card once it renders", async () => { + health = fixtures.macOff; + const host = await mountStartup("http://localhost/#startup/desktop"); + const card = desktopCard(host); + expect(card).not.toBeNull(); + expect(document.activeElement).toBe(card); + expect(card.tabIndex).toBe(-1); + expect(win.location.hash).toBe("#startup/desktop"); +}); + +test("a later refresh does not take focus back, and a new arrival focuses again", async () => { + let reads = 0; + health = () => ++reads === 1 ? { ...fixtures.macOff, diagnosticStale: true } : fixtures.macOff; + const host = await mountStartup("http://localhost/#startup/desktop"); + const other = document.createElement("button"); + host.append(other); + other.focus(); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 2100)); }); + expect(reads).toBe(2); + expect(document.activeElement).toBe(other); + await act(async () => { + win.location.hash = "#startup"; + win.location.hash = "#startup/desktop"; + await new Promise(resolve => setTimeout(resolve, 30)); + }); + expect(document.activeElement).toBe(desktopCard(host)); +}); + +test("#startup/desktop without a Desktop card does nothing", async () => { + health = fixtures.browserService; + const host = await mountStartup("http://localhost/#startup/desktop"); + expect(desktopCard(host)).toBeNull(); + expect(document.activeElement).toBe(document.body); + expect(win.location.hash).toBe("#startup/desktop"); +}); + +test("plain #startup leaves focus alone", async () => { + health = fixtures.macOff; + const host = await mountStartup("http://localhost/#startup"); + expect(desktopCard(host)).not.toBeNull(); + expect(document.activeElement).toBe(document.body); +}); + +test("a stopped Desktop card carries its next action", async () => { + health = fixtures.macStoppedLocal; + const host = await mountStartup("http://localhost/#startup/desktop"); + expect(desktopCard(host).textContent).toContain("Reopen OpenCodex and check Start at Login."); +}); + diff --git a/gui/tests/startup-checklist-refresh.test.tsx b/gui/tests/startup-checklist-refresh.test.tsx index 2d78c21db8b..d53c01d3e74 100644 --- a/gui/tests/startup-checklist-refresh.test.tsx +++ b/gui/tests/startup-checklist-refresh.test.tsx @@ -33,9 +33,14 @@ afterEach(async () => { } }); -const stale = { status: "at-risk", diagnosticStale: true, routingKind: "opencodex-local", desktop: { owned: true }, protection: "none" }; +// Browser readings: the step follows the background service or shim. +const stale = { status: "at-risk", diagnosticStale: true, routingKind: "opencodex-local", protection: "none", platform: "darwin" }; const protectedHealth = { ...stale, status: "protected", diagnosticStale: false, protection: "service" }; -const disabledHealth = { ...stale, diagnosticStale: false, desktop: { owned: true, loginState: "disabled" } }; +const disabledHealth = { ...stale, diagnosticStale: false }; +// Desktop readings: the step follows the app's Start at Login. +const desktopStale = { ...stale, desktop: { owned: true, loginEnabled: true, loginState: "enabled", running: true, viable: true } }; +const desktopOn = { ...desktopStale, status: "protected", diagnosticStale: false, protection: "desktop" }; +const desktopOff = { ...desktopStale, diagnosticStale: false, desktop: { owned: true, loginEnabled: false, loginState: "disabled", running: true, viable: false } }; async function render(apiBase: string): Promise { const host = document.createElement("div"); @@ -54,7 +59,7 @@ function pages(apiBase: string) { } function rows(host: HTMLElement): HTMLElement[] { - return [...host.querySelectorAll("li")].filter(row => row.textContent?.includes("Start automatically")); + return [...host.querySelectorAll('li[data-step="autostart"]')]; } test("a failed stale refresh stops polling", async () => { @@ -114,17 +119,29 @@ test("shared consumers do not overlap refreshes and unmount aborts the request", expect(refreshSignal?.aborted).toBe(true); }); -for (const [label, fresh, expected] of [["protected", protectedHealth, "done"], ["disabled", disabledHealth, "todo"]] as const) { +const refreshCases = [ + ["protected", stale, protectedHealth, "done", "Start automatically", "Keep opencodex"], + ["disabled", stale, disabledHealth, "todo", "Start automatically", "Keep opencodex"], + ["Desktop login on", desktopStale, desktopOn, "done", "Turn on Start at Login", "make sure Start at Login is checked"], + ["Desktop login off", desktopStale, desktopOff, "todo", "Turn on Start at Login", "make sure Start at Login is checked"], +] as const; + +for (const [label, first, fresh, expected, title, hint] of refreshCases) { test(`both checklists refresh stale startup health to ${label}`, async () => { let reads = 0; globalThis.fetch = (async (input: RequestInfo | URL) => { - if (String(input).includes("/api/startup-health")) return Response.json(++reads === 1 ? stale : fresh); + if (String(input).includes("/api/startup-health")) return Response.json(++reads === 1 ? first : fresh); return Response.json({}); }) as typeof fetch; - const host = await render(`http://checklist-${label}`); + const host = await render(`http://checklist-${label.replaceAll(" ", "-")}`); expect(rows(host)).toHaveLength(2); - expect(rows(host)[0].textContent).not.toContain("Keep opencodex"); + expect(rows(host)[0].textContent).toContain(title); + // A stale reading is unknown: no tick and no "To do". Only the Desktop step keeps its hint, + // so an unverified Start at Login still says where to check it. + expect(rows(host)[0].textContent?.includes(hint)).toBe(first === desktopStale); + expect(rows(host)[0].textContent).not.toContain("✓"); expect(rows(host)[1].textContent).not.toContain("To do"); + expect(rows(host)[1].textContent).not.toContain("Done"); expect(reads).toBe(1); await act(async () => { await new Promise(resolve => setTimeout(resolve, 2200)); }); expect(reads).toBe(2); @@ -132,7 +149,7 @@ for (const [label, fresh, expected] of [["protected", protectedHealth, "done"], expect(rows(host)[0].textContent).toContain("✓"); expect(rows(host)[1].textContent).toContain("Done"); } else { - expect(rows(host)[0].textContent).toContain("Keep opencodex"); + expect(rows(host)[0].textContent).toContain(hint); expect(rows(host)[1].textContent).toContain("To do"); } }); diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 5391d9fc5d6..82ce7d7dda0 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -499,7 +499,7 @@ unvalidated Bun builds is unchanged (`src/lib/bun-stream-caps.ts`). ## Startup safety -Startup safety credits macOS and Linux `desktop` protection only after matching the durable app ownership or live supervision, an enabled login registration (macOS: loaded in this session or registered for the next login; Linux: enabled XDG autostart entry), and live app-to-bundled-proxy process relationship. This does not claim an independently installed CLI service. Missing or stale evidence remains at risk. The durable desktop claim remains visible when identity, login registration, or supervision fails; service install and repair controls and their copyable command stay disabled while the app owns or supervises the runtime. Shim controls stay enabled because `ocx ensure` (`src/cli/ensure-desktop-cold-start.ts`) waits for a running Desktop (matched by executable identity), relaunches a claimed macOS Desktop under a pid-held launch lease, and otherwise fails rather than starting a CLI proxy, including when ownership or the process list cannot be read. The relaunch selects the absolute path recorded by the installation's login registration, after the app-local install id matches the claim, the canonical executable is a regular executable file inside a `.app`, and that bundle's `CFBundleIdentifier` and `CFBundleExecutable` identify the same Desktop executable. A bundle-identifier lookup never chooses the launch target; missing, relative or inconsistent registration evidence refuses with manual-open guidance. Under Desktop the shim installer child gets the login-shell PATH (`src/server/login-shell-path.ts`); an intact overlay not active on the dashboard's PATH reports `shimActivationPending` and shows the activation step instead of Repair. Desktop login evidence is enabled, disabled, or unknown. The dashboard displays unknown or stale Desktop evidence as an informational unverified state, while its backend safety verdict remains conservative; fresh confirmed supervision failure and verified disabled login without other protection receive an action-required state. An unreadable macOS login or process probe marks supervision unknown rather than proving it failed. The hero asks the user to check Start at Login in the OpenCodex menu once, cards show short status, and recovery lists commands. Compiled startup probes and the dashboard installer child (`runCliInstall` in `src/server/startup-action-control.ts`) use the standalone-aware self-launch argument builder. +Startup safety credits macOS and Linux `desktop` protection only after matching the durable app ownership or live supervision, an enabled login registration (macOS: loaded in this session or registered for the next login; Linux: enabled XDG autostart entry), and live app-to-bundled-proxy process relationship. This does not claim an independently installed CLI service. Missing or stale evidence remains at risk. The durable desktop claim remains visible when identity, login registration, or supervision fails; service install and repair controls and their copyable command stay disabled while the app owns or supervises the runtime. Shim controls stay enabled because `ocx ensure` (`src/cli/ensure-desktop-cold-start.ts`) waits for a running Desktop (matched by executable identity), relaunches a claimed macOS Desktop under a pid-held launch lease, and otherwise fails rather than starting a CLI proxy, including when ownership or the process list cannot be read. The relaunch selects the absolute path recorded by the installation's login registration, after the app-local install id matches the claim, the canonical executable is a regular executable file inside a `.app`, and that bundle's `CFBundleIdentifier` and `CFBundleExecutable` identify the same Desktop executable. A bundle-identifier lookup never chooses the launch target; missing, relative or inconsistent registration evidence refuses with manual-open guidance. Under Desktop the shim installer child gets the login-shell PATH (`src/server/login-shell-path.ts`); an intact overlay not active on the dashboard's PATH reports `shimActivationPending` and shows the activation step instead of Repair. Desktop login evidence is enabled, disabled, or unknown. The dashboard displays unknown or stale Desktop evidence as an informational unverified state, while its backend safety verdict remains conservative; fresh confirmed supervision failure and verified disabled login without other protection receive an action-required state. An unreadable macOS login or process probe marks supervision unknown rather than proving it failed. The hero asks the user to check Start at Login in the OpenCodex menu once, cards show short status, and recovery lists commands. A stopped Desktop card carries the short reopen-and-check line instead of an empty body. The Getting started step on Home and the Set up hub (`startupChecklistStep` in `gui/src/startup-health-ui.ts`) follows the same Desktop evidence when the reading names a Desktop owner or supervisor: it is titled for Start at Login, never completed by a service or shim, done only when the reading is viable, to do when login is verified disabled or macOS confirms the supervisor is missing, and unknown otherwise, which covers stale readings and every Windows and Linux reading that is not viable. It opens `#startup/desktop`, which focuses the Desktop card once per arrival (`gui/src/pages/startup-desktop-focus.ts`). Without a Desktop owner the step keeps the service and shim rule and opens `#startup`. Compiled startup probes and the dashboard installer child (`runCliInstall` in `src/server/startup-action-control.ts`) use the standalone-aware self-launch argument builder. **Startup safety** is reachable by route (`/#startup`) and rendered by the app, but it is not a sidebar entry: it is entered from the dashboard's startup-state row, which links there whether the From a52b90deb1444a31a6f0aca72cc4a092d7b1a067 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 10 Nov 2026 20:45:25 +0900 Subject: [PATCH 2/3] docs(devlog): record desktop checklist evidence --- .../_plan/261110_desktop_autostart_checklist/000_plan.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/devlog/_plan/261110_desktop_autostart_checklist/000_plan.md b/devlog/_plan/261110_desktop_autostart_checklist/000_plan.md index ed6488b1ff1..25460d52113 100644 --- a/devlog/_plan/261110_desktop_autostart_checklist/000_plan.md +++ b/devlog/_plan/261110_desktop_autostart_checklist/000_plan.md @@ -107,3 +107,10 @@ Reflection (run 3): not-ready because the supervision-failure arm would return f Reviewer hyds/deepseek-v4.1-flash-free (task desktop-checklist-audit-a1): GO-WITH-FIXES (blockers=2). Folded: (1) routing-independent supervision-failure arm in D2; (2) A3 uses the pinned runner. Also folded: platform in the body type, named consumer and routing tests, `data-step` selector, matrix rows for macOS and Linux, docs-site build, v2-only note, D6 guard, browser arm delegates to `startupChecklistDone`, D8 hidden while failed. A mistral fallback audit was cancelled after the primary finished. The D2 change goes to the architect for reflection. Architect recheck (run 4): ALIGNED with the routing-independent arm; its guard notes are folded into D2. Main judgment: near-pass. Both High blockers folded, no residual blockers. + +## Evidence + +- Red on unchanged source `05d517e960`: `cd gui && bun ../scripts/test-with-pinned-bun.ts --isolate tests/startup-checklist-desktop.test.tsx` exited 1 with 17 fail and 3 pass. The three passes are the do-nothing cases (no reading yet, no Desktop card, plain `#startup`). The browser cases fail only on the new `data-step` selector; their copy, completion and destination already matched. +- Green after the change: the same file 20 pass; the A3 GUI set 100 pass across 10 files; root set 70 pass across 5 files; `bun run test:changed` 322 pass; lint, typecheck, `build:gui` (`tsc -b`), `structure:check`, `privacy:scan` and the docs-site build exit 0. After rebasing onto `fdcf20b74c` the core GUI set (60 pass) and typecheck were rerun. +- The refresh test's fixtures were split: browser readings keep the original assertions; Desktop readings run the same refresh cases with Desktop copy, and their stale stage expects the hint (D6). +- Rendered with headless Chrome against a mocked API serving `gui/dist`: Desktop on, Desktop off, browser, Set up hub and the focused Desktop card on `#startup/desktop`. Uploaded to `pr-assets` under `desktop-autostart-checklist-261110/`. From d9b5672b59b27d09b18d649774628fe7e4e74b49 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 10 Nov 2026 21:21:34 +0900 Subject: [PATCH 3/3] fix(gui): align the German menu term and document every Desktop checklist state The German hint now quotes the menu item as the Startup page does. The desktop guide explains when the step is done, to do or unmarked on macOS, Windows and Linux, and the focus test waits for the stale retry instead of a fixed sleep. --- docs-site/src/content/docs/guides/desktop-app.md | 8 +++++--- gui/src/i18n/shell-v2-copy.ts | 2 +- gui/tests/startup-checklist-desktop.test.tsx | 6 +++++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index e16ad51ab04..3ef800fc2fe 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -138,9 +138,11 @@ in the OpenCodex menu instead of installing a background service. Startup safety 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. On -Windows the registration is not verified, so the step stays unmarked; check the item in -the OpenCodex menu yourself. +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 diff --git a/gui/src/i18n/shell-v2-copy.ts b/gui/src/i18n/shell-v2-copy.ts index 9350a33ae44..c79a61b9103 100644 --- a/gui/src/i18n/shell-v2-copy.ts +++ b/gui/src/i18n/shell-v2-copy.ts @@ -58,7 +58,7 @@ const rows = { 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 „Start beim Anmelden“ 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."], + 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."], diff --git a/gui/tests/startup-checklist-desktop.test.tsx b/gui/tests/startup-checklist-desktop.test.tsx index c79b5b7fbca..e7035db9f7a 100644 --- a/gui/tests/startup-checklist-desktop.test.tsx +++ b/gui/tests/startup-checklist-desktop.test.tsx @@ -213,7 +213,11 @@ test("a later refresh does not take focus back, and a new arrival focuses again" const other = document.createElement("button"); host.append(other); other.focus(); - await act(async () => { await new Promise(resolve => setTimeout(resolve, 2100)); }); + // Wait for Startup's stale retry (2 s) instead of racing a fixed sleep against it. + for (let waited = 0; reads < 2 && waited < 6000; waited += 100) { + await act(async () => { await new Promise(resolve => setTimeout(resolve, 100)); }); + } + await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); }); expect(reads).toBe(2); expect(document.activeElement).toBe(other); await act(async () => {