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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- Prefer smaller, focused style files (≈150 lines or less) over large monoliths. Split by component or feature area if a file grows beyond that size.
- Co-locate reusable UI patterns (buttons, selectors, dropdowns, etc.) under `src/styles/components/` and avoid redefining the same utility classes elsewhere.
- Use the shared `.window-*` primitives from `src/styles/components/window.css` for dialog, popover, and floating-window headers, toolbars, bodies, footers, titles, and actions.
- Authentication recovery uses `lib/auth-recovery.ts` and `components/auth-recovery-dialog.tsx`: confirm CodeNomad auth status before showing an expired-login form, reconnect in place to preserve drafts, and never replay failed mutations. Its shared window styles live in `styles/components/auth-recovery.css`.
- Persistent command/search utility windows use `components/dismissible-window.tsx`: non-modal, no scrim, outside interactions keep them open, and explicit toggles use `.icon-toggle` with `aria-expanded`/`aria-controls`. Keep search state scoped to its instance/session and close it when that view becomes inactive.
- The composer reserves `/btw` for native `session.generate`, outside ordinary prompt/command submission. Its ephemeral question/answer window uses `DismissibleWindow` and `styles/components/session-aside.css`; cancellation and inactive/session transitions fence late results without interrupting the main session.
- OpenCode settings keep executable selection first and runtime status, install/update and service actions directly inline. Only version details and troubleshooting are collapsed disclosures at the bottom of the runtime panel; log levels remain the final settings card. Share controls with the startup recovery dialog rather than routing settings through a separate management modal. Disclosure styles live in `styles/components/opencode-setup.css`.
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Toaster } from "solid-toast"
import useMediaQuery from "@suid/material/useMediaQuery"
import { Minimize2 } from "lucide-solid"
import AlertDialog from "./components/alert-dialog"
import AuthRecoveryDialog from "./components/auth-recovery-dialog"
import FolderSelectionView from "./components/folder-selection-view"
import { useDesktopFolderLaunch } from "./lib/hooks/use-electron-folder-launch"
import { showConfirmDialog } from "./stores/alerts"
Expand Down Expand Up @@ -898,6 +899,7 @@ const App: Component = () => {
<SettingsScreen />
<SideCarPickerDialog open={sidecarPickerOpen()} onClose={() => setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} />
<AlertDialog />
<AuthRecoveryDialog />

<Toaster
position="top-right"
Expand Down
6 changes: 4 additions & 2 deletions packages/ui/src/components/alert-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Component, Show, createEffect, createSignal } from "solid-js"
import { alertDialogState, dismissAlertDialog } from "../stores/alerts"
import type { AlertVariant, AlertDialogState } from "../stores/alerts"
import { useI18n } from "../lib/i18n"
import { authRecovery } from "../lib/auth-recovery"

const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string; badgeText: string; symbol: string }> = {
info: {
Expand Down Expand Up @@ -64,9 +65,10 @@ const AlertDialog: Component = () => {

createEffect(() => {
const state = alertDialogState()
if (!state) return
if (!state || authRecovery.required()) return

queueMicrotask(() => {
if (authRecovery.required() || alertDialogState() !== state) return
if (state.type === "prompt") {
promptInputRef?.focus()
promptInputRef?.select()
Expand Down Expand Up @@ -105,7 +107,7 @@ const AlertDialog: Component = () => {

return (
<Dialog
open
open={!authRecovery.required()}
modal
onOpenChange={(open) => {
// Only handle dismiss if dialog is dismissible (default: true)
Expand Down
60 changes: 60 additions & 0 deletions packages/ui/src/components/auth-recovery-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Dialog } from "@kobalte/core/dialog"
import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js"
import { authRecovery } from "../lib/auth-recovery"
import { useI18n } from "../lib/i18n"

export default function AuthRecoveryDialog() {
const { t } = useI18n()
const [username, setUsername] = createSignal("codenomad")
const [password, setPassword] = createSignal("")
const [pending, setPending] = createSignal(false)
const [error, setError] = createSignal<"credentials" | "unavailable" | "">("")
createEffect(() => { if (!authRecovery.required()) { setPassword(""); setError("") } })
onMount(() => {
const check = () => { if (authRecovery.required()) void authRecovery.check() }
window.addEventListener("focus", check)
onCleanup(() => window.removeEventListener("focus", check))
})

async function signIn(event: SubmitEvent) {
event.preventDefault()
if (pending()) return
setPending(true)
setError("")
const result = await authRecovery.signIn(username(), password())
setPassword("")
if (result !== "ok") setError(result)
setPending(false)
}

return (
<Dialog open={authRecovery.required()} modal>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay auth-recovery-overlay" />
<Dialog.Content class="modal-surface window-shell auth-recovery-window"
onEscapeKeyDown={event => { event.preventDefault(); event.stopImmediatePropagation() }}
onInteractOutside={event => event.preventDefault()}>
<div class="window-header"><Dialog.Title class="window-title">{t("authRecovery.title")}</Dialog.Title></div>
<form onSubmit={signIn}>
<div class="window-body">
<Dialog.Description>{t("authRecovery.description")}</Dialog.Description>
<p>{t("authRecovery.drafts")}</p>
<label for="auth-recovery-username">{t("authRecovery.username")}</label>
<input id="auth-recovery-username" class="form-input" name="username" autocomplete="username"
autocapitalize="none" autocorrect="off" spellcheck={false} required
value={username()} onInput={event => setUsername(event.currentTarget.value)} disabled={pending()} />
<label for="auth-recovery-password">{t("authRecovery.password")}</label>
<input id="auth-recovery-password" class="form-input" name="password" type="password" autocomplete="current-password"
required value={password()} onInput={event => setPassword(event.currentTarget.value)} disabled={pending()} />
<Show when={error()}><p role="alert">{t(error() === "credentials" ? "authRecovery.credentials" : "authRecovery.unavailable")}</p></Show>
</div>
<div class="window-footer">
<button type="button" class="window-action" disabled={pending()} onClick={() => void authRecovery.check()}>{t("authRecovery.check")}</button>
<button type="submit" class="window-action" disabled={pending()}>{t(pending() ? "authRecovery.pending" : "authRecovery.signIn")}</button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
)
}
4 changes: 4 additions & 0 deletions packages/ui/src/lib/api-base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const runtimeBase = typeof window !== "undefined" ? window.location?.origin : undefined
const defaultBase = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? runtimeBase : undefined

export const CODENOMAD_API_BASE = import.meta.env?.VITE_CODENOMAD_API_BASE ?? defaultBase
9 changes: 4 additions & 5 deletions packages/ui/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,10 @@ import { getClientIdentity } from "./client-identity"
import { getLogger } from "./logger"
import { attachEventSourceHandlers } from "./event-source-handlers"
import { HttpResponseError, retryFileSearch } from "./retryable-file-search"
import { authenticatedFetch } from "./auth-recovery"
import { CODENOMAD_API_BASE as API_BASE } from "./api-base"

const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? RUNTIME_BASE : undefined
const DEFAULT_EVENTS_PATH = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events"
const API_BASE = import.meta.env?.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE
const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH)

export const CODENOMAD_API_BASE = API_BASE
Expand Down Expand Up @@ -136,7 +135,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
logHttp(`${method} ${path}`)

try {
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
const response = await authenticatedFetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
if (!response.ok) {
const message = await readErrorMessage(response)
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
Expand Down Expand Up @@ -165,7 +164,7 @@ async function requestRaw(path: string, init?: RequestInit): Promise<Response> {
const startedAt = Date.now()
logHttp(`${method} ${path}`)

const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
const response = await authenticatedFetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
if (!response.ok) {
const message = await readErrorMessage(response)
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
Expand Down
86 changes: 86 additions & 0 deletions packages/ui/src/lib/auth-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { createAuthRecovery } from "./auth-recovery"

function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(done => { resolve = done })
return { promise, resolve }
}

test("coalesces status checks and only explicit unauthenticated status opens recovery", async () => {
let calls = 0
const response = deferred<Response>()
const recovery = createAuthRecovery("https://fixture.invalid", async (input, init) => {
calls++
assert.equal(String(input), "https://fixture.invalid/api/auth/status")
assert.equal(init?.credentials, "include")
assert.equal(init?.cache, "no-store")
return response.promise
})
const first = recovery.check(), second = recovery.check()
assert.equal(calls, 1)
response.resolve(Response.json({ authenticated: false }))
await Promise.all([first, second])
assert.equal(recovery.required(), true)
for (const body of [{}, { authenticated: "false" }, { authenticated: true }]) {
const other = createAuthRecovery(undefined, async () => Response.json(body))
await other.check()
assert.equal(other.required(), false)
}
for (const result of [async () => new Response("down", { status: 503 }), async () => { throw Error("offline") }]) {
const other = createAuthRecovery(undefined, result)
await other.check()
assert.equal(other.required(), false)
}
})

test("login fences probes begun before and during authentication", async () => {
for (const during of [false, true]) {
const status = deferred<Response>(), login = deferred<Response>()
let restored = 0, requests = 0
const recovery = createAuthRecovery(undefined, async (input) => {
if (String(input).endsWith("/login")) return login.promise
requests++
return requests === 1 ? Response.json({ authenticated: false }) : status.promise
})
recovery.onRestored(() => { restored++ })
await recovery.check()
const pendingStatus = during ? undefined : recovery.check()
const pendingLogin = recovery.signIn("person", "secret")
const duringStatus = during ? recovery.check() : undefined
login.resolve(Response.json({ ok: true }))
assert.equal(await pendingLogin, "ok")
status.resolve(Response.json({ authenticated: false }))
await Promise.all([pendingStatus, duringStatus])
assert.equal(recovery.required(), false)
assert.equal(restored, 1)
}
})

test("wrong credentials, offline login and malformed success preserve recovery without replay", async () => {
for (const [status, body, expected] of [[401, {}, "credentials"], [503, {}, "unavailable"], [200, {}, "unavailable"]] as const) {
const requests: string[] = []
const recovery = createAuthRecovery(undefined, async (input) => {
requests.push(String(input))
return String(input).endsWith("/status") ? Response.json({ authenticated: false }) : Response.json(body, { status })
})
await recovery.check()
assert.equal(await recovery.signIn("person", "secret"), expected)
assert.equal(recovery.required(), true)
assert.deepEqual(requests, ["/api/auth/status", "/api/auth/login"])
}
})

test("a renewed cookie in another tab restores once without a login POST", async () => {
let authenticated = false, notifications = 0
const recovery = createAuthRecovery(undefined, async () => Response.json({ authenticated }))
const unsubscribe = recovery.onRestored(() => { notifications++ })
await recovery.check()
authenticated = true
await recovery.check()
await recovery.check()
assert.equal(recovery.required(), false)
assert.equal(notifications, 1)
unsubscribe()
})
74 changes: 74 additions & 0 deletions packages/ui/src/lib/auth-recovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { createSignal } from "solid-js"
import { CODENOMAD_API_BASE } from "./api-base"

/** Browser login recovery only. Never retries a failed application request or
* interprets an upstream OpenCode 401 as proof of an expired CodeNomad login. */
export function createAuthRecovery(base: string | undefined, fetcher: typeof fetch = (...args) => fetch(...args)) {
const [required, setRequired] = createSignal(false)
const restored = new Set<() => void>()
let generation = 0
let checking: Promise<void> | undefined
const url = (path: string) => base ? new URL(path, base).toString() : path

function authenticated() {
const wasRequired = required()
setRequired(false)
if (wasRequired) for (const handler of restored) handler()
}

function check(): Promise<void> {
if (checking) return checking
const current = generation
const pending = (async () => {
try {
const response = await fetcher(url("/api/auth/status"), {
credentials: "include", cache: "no-store", signal: AbortSignal.timeout(8000),
})
if (!response.ok) return
const status = await response.json()
if (current !== generation) return
if (status?.authenticated === false) setRequired(true)
else if (status?.authenticated === true) authenticated()
} catch { /* Offline/unreachable is not evidence of an expired login. */ }
})()
checking = pending
void pending.finally(() => { if (checking === pending) checking = undefined })
return pending
}

async function signIn(username: string, password: string): Promise<"ok" | "credentials" | "unavailable"> {
// Fence probes started before this login, including their response bodies.
const current = ++generation
checking = undefined
try {
const response = await fetcher(url("/api/auth/login"), {
method: "POST", credentials: "include", cache: "no-store",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }), signal: AbortSignal.timeout(15000),
})
if (current !== generation) return "unavailable"
if (response.status === 401) return "credentials"
if (!response.ok) return "unavailable"
const body = await response.json()
if (current !== generation || body?.ok !== true) return "unavailable"
// A probe issued while login was in flight may still carry the old cookie.
generation++
checking = undefined
authenticated()
return "ok"
} catch { return "unavailable" }
}

return {
required, check, signIn,
onRestored(handler: () => void) { restored.add(handler); return () => restored.delete(handler) },
}
}

export const authRecovery = createAuthRecovery(CODENOMAD_API_BASE)

export const authenticatedFetch: typeof fetch = async (input, init) => {
const response = await globalThis.fetch(input, init)
if (response.status === 401) void authRecovery.check()
return response
}
7 changes: 7 additions & 0 deletions packages/ui/src/lib/hooks/use-app-session-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,13 @@ export function useAppSessionCapture() {
onInstanceLifecycleAuthority((event) => {
const lifecycleToken = ++nextInstanceLifecycleToken
instanceLifecycleTokens.set(event.instanceId, lifecycleToken)
// A fresh page has no startup snapshot. Capture live work before an
// unavailable workspace is removed during backend-restart reconciliation.
if (!preservation && event.type === "unavailable") {
const captured = captureState(scrollAuthority)
preservation = createRestorableSessionPreservation(captured.state)
captured.tabIds.forEach((id, index) => recordRestoredTab(preservation!, index, id))
}
if (!preservation) {
if (event.type === "removed") {
const authoritativeState = captureState(scrollAuthority).state
Expand Down
10 changes: 10 additions & 0 deletions packages/ui/src/lib/i18n/messages/de/dialogs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
export const dialogMessages = {
"authRecovery.title": "Erneut bei CodeNomad anmelden",
"authRecovery.description": "Ihre CodeNomad-Anmeldung ist nicht mehr gültig. Dies kann nach einem Serverneustart passieren. Melden Sie sich erneut an, um die Verbindung wiederherzustellen.",
"authRecovery.drafts": "Diese Seite bleibt geöffnet, damit Ihre Entwürfe erhalten bleiben. Wiederholen Sie nach der Anmeldung die fehlgeschlagene Aktion.",
"authRecovery.username": "Benutzername",
"authRecovery.password": "Passwort",
"authRecovery.signIn": "Anmelden",
"authRecovery.pending": "Anmeldung läuft…",
"authRecovery.check": "Verbindung prüfen",
"authRecovery.credentials": "Benutzername oder Passwort ist falsch.",
"authRecovery.unavailable": "Anmeldung nicht möglich. Prüfen Sie, ob der CodeNomad-Server erreichbar ist, und versuchen Sie es erneut.",
"alertDialog.fallbackTitle.info": "Hinweis",
"alertDialog.fallbackTitle.warning": "Bitte überprüfen",
"alertDialog.fallbackTitle.error": "Etwas ist schiefgelaufen",
Expand Down
10 changes: 10 additions & 0 deletions packages/ui/src/lib/i18n/messages/en/dialogs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
export const dialogMessages = {
"authRecovery.title": "Sign in to CodeNomad again",
"authRecovery.description": "Your CodeNomad login is no longer valid. This can happen after the server restarts. Sign in again to reconnect.",
"authRecovery.drafts": "This page stays open to keep your drafts. After reconnecting, retry the action that failed.",
"authRecovery.username": "Username",
"authRecovery.password": "Password",
"authRecovery.signIn": "Sign in",
"authRecovery.pending": "Signing in…",
"authRecovery.check": "Check connection",
"authRecovery.credentials": "The username or password is incorrect.",
"authRecovery.unavailable": "Unable to sign in. Check that the CodeNomad server is available and try again.",
"alertDialog.fallbackTitle.info": "Heads up",
"alertDialog.fallbackTitle.warning": "Please review",
"alertDialog.fallbackTitle.error": "Something went wrong",
Expand Down
10 changes: 10 additions & 0 deletions packages/ui/src/lib/i18n/messages/es/dialogs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
export const dialogMessages = {
"authRecovery.title": "Volver a iniciar sesión en CodeNomad",
"authRecovery.description": "Tu sesión de CodeNomad ya no es válida. Esto puede ocurrir después de reiniciar el servidor. Inicia sesión de nuevo para reconectarte.",
"authRecovery.drafts": "Esta página permanece abierta para conservar tus borradores. Tras reconectarte, vuelve a intentar la acción que falló.",
"authRecovery.username": "Nombre de usuario",
"authRecovery.password": "Contraseña",
"authRecovery.signIn": "Iniciar sesión",
"authRecovery.pending": "Iniciando sesión…",
"authRecovery.check": "Comprobar conexión",
"authRecovery.credentials": "El nombre de usuario o la contraseña son incorrectos.",
"authRecovery.unavailable": "No se pudo iniciar sesión. Comprueba que el servidor CodeNomad esté disponible e inténtalo de nuevo.",
"alertDialog.fallbackTitle.info": "Aviso",
"alertDialog.fallbackTitle.warning": "Por favor revisa",
"alertDialog.fallbackTitle.error": "Algo salió mal",
Expand Down
Loading
Loading