diff --git a/apps/contract/src/contract.ts b/apps/contract/src/contract.ts index 85cf9fd7..700e6b03 100644 --- a/apps/contract/src/contract.ts +++ b/apps/contract/src/contract.ts @@ -163,6 +163,15 @@ export const ServerStatus = Schema.Struct({ }); export type ServerStatus = typeof ServerStatus.Type; +/** How often every server is checked again in the background. */ +export const ServerHealth = Schema.Struct({ + /** Seconds between checks; 0 turns them off. */ + interval: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), +}); +export type ServerHealth = typeof ServerHealth.Type; + +export const DEFAULT_SERVER_HEALTH: ServerHealth = { interval: 300 }; + export const CliAction = Schema.Literals(["start", "restart", "stop"]); export type CliAction = typeof CliAction.Type; @@ -242,9 +251,24 @@ export const Api = RpcGroup.make( success: Schema.Void, error: DeskError, }), - /** Every server, the built-in one first, then in the order added. */ - Rpc.make("servers.list", { + /** + * Every server, the built-in one first, then in the order added: as last checked, then again each + * time a check finds a change. + */ + Rpc.make("servers.watch", { success: Schema.Array(ServerStatus), + stream: true, + }), + /** Checks every server now; done once the new statuses are out. */ + Rpc.make("servers.check", { + success: Schema.Void, + }), + Rpc.make("servers.health", { + success: ServerHealth, + }), + Rpc.make("servers.setHealth", { + payload: ServerHealth, + success: Schema.Void, }), Rpc.make("servers.add", { payload: ServerAdd, diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index a3f1ac26..5517fec2 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -4,15 +4,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import os from "node:os"; import path from "node:path"; import { Codework } from "./codework.ts"; -import { - type CliAction, - type CliInstall, - type CliServer, - type ServerAdd, - ServersError, - type ServerStatus, - type ServerUpdate, -} from "@codeworksh/contract"; +import { type CliAction, type CliInstall, type CliServer, ServersError, type ServerStatus } from "@codeworksh/contract"; import { Servers } from "./servers.ts"; // Where the install script puts the binary unless told otherwise. @@ -131,15 +123,22 @@ const alive = (pid: number) => } }); +/** The server a home names, while its process lives. */ +export const liveAt = (home: string) => + Effect.gen(function* () { + const registered = yield* Codework.registrationAt(home); + return Option.isSome(registered) && (yield* alive(registered.value.pid)) ? registered : Option.none(); + }); + /** * The server a home names. The default one is asked through the app's own connection, since a build * whose replies differ still answers `server.info`: a call the app depends on decides whether it * works with this app. Any other is taken from its registration and process. */ -const serverAt = (home: string, isDefault: boolean) => +export const serverAt = (home: string, isDefault: boolean) => Effect.gen(function* () { - const registered = yield* Codework.registrationAt(home); - if (Option.isNone(registered) || !(yield* alive(registered.value.pid))) return { _tag: "Stopped" } as const; + const registered = yield* liveAt(home); + if (Option.isNone(registered)) return { _tag: "Stopped" } as const; const { url, pid, version: registeredVersion } = registered.value; if (!isDefault) return { _tag: "Running", url, pid, version: registeredVersion } satisfies CliServer; const info = yield* Codework.call((client) => client("server.info", {})).pipe(Effect.option); @@ -151,7 +150,7 @@ const serverAt = (home: string, isDefault: boolean) => // The app's connection reconnects with a backoff; waiting until it reaches the server keeps the // status read right after from calling a healthy server unreachable. Only when there is one to reach. -const awaitConnection = Effect.gen(function* () { +export const awaitConnection = Effect.gen(function* () { if (Option.isNone(yield* Codework.registration)) return; yield* Codework.call((client) => client("server.info", {})).pipe( Effect.retry(Schedule.spaced("250 millis")), @@ -160,88 +159,56 @@ const awaitConnection = Effect.gen(function* () { ); }); -export const handlers = { - "servers.list": Effect.fn("servers.list")(function* () { - const servers = yield* Servers.Service; - const foundOnce = yield* Effect.cached(found); - return yield* Effect.forEach( - yield* servers.list, - (row) => - Effect.gen(function* () { - const home = servers.homeOf(row); - const [cli, server] = yield* Effect.all( - [executableOf(row, foundOnce), serverAt(home.path, row.isDefault)], - { - concurrency: "unbounded", - }, - ); - return { - id: row.id, - kind: row.kind, - name: row.name, - isDefault: row.isDefault, - home, - cli, - server, - args: row.args, - env: row.env.map(({ name, sensitive, value }) => - sensitive ? { name, sensitive } : { name, sensitive, value }, - ), - } satisfies ServerStatus; - }), - { concurrency: "unbounded" }, - ); - }), - - "servers.add": Effect.fn("servers.add")(function* (input: ServerAdd) { - return yield* (yield* Servers.Service).add(input); - }), - - "servers.update": Effect.fn("servers.update")(function* (input: ServerUpdate) { - const servers = yield* Servers.Service; - const before = yield* servers.defaultHome; - yield* servers.update(input); - if ((yield* servers.defaultHome) !== before) yield* awaitConnection; - }), - - "servers.remove": Effect.fn("servers.remove")(function* ({ id }: { readonly id: string }) { - yield* (yield* Servers.Service).remove(id); - }), - - "servers.setDefault": Effect.fn("servers.setDefault")(function* ({ id }: { readonly id: string }) { - const servers = yield* Servers.Service; - const before = yield* servers.defaultHome; - yield* servers.setDefault(id); - if ((yield* servers.defaultHome) !== before) yield* awaitConnection; - }), - - // The CLI owns a server's lifecycle; the app only asks it, with the server's variables and in its - // home, which a variable never overrides. - "servers.service": Effect.fn("servers.service")(function* ({ - id, - action, - }: { - readonly id: string; - readonly action: CliAction; - }) { - const servers = yield* Servers.Service; - const row = yield* servers.get(id); - const cli = yield* executableOf(row, found); - if (cli === null || !("version" in cli)) - return yield* new ServersError({ message: "No working codework CLI was found." }); - const env = Object.fromEntries(row.env.map(({ name, value }) => [name, value])); - // `stop` takes no server options, so the arguments go only to what starts a server. - const args = action === "stop" ? [] : Servers.splitArgs(row.args); - const { file, prefix } = row.executable === null ? { file: cli.path, prefix: [] } : split(row.executable); - const { output, code } = yield* run(file, [...prefix, "service", action, ...args], "30 seconds", { - ...env, - CODEWORK_HOME_DIR: servers.homeOf(row).path, - }).pipe( - Effect.mapError((error) => new ServersError({ message: `codework service ${action}: ${error.message}` })), - ); - if (code !== 0) return yield* new ServersError({ message: `codework service ${action} ${reason(output, code)}` }); - if (row.isDefault && action !== "stop") yield* awaitConnection; - }), -}; +/** Every server and how it is set up, with its executable and process as found now. */ +export const statuses = Effect.gen(function* () { + const servers = yield* Servers.Service; + const foundOnce = yield* Effect.cached(found); + return yield* Effect.forEach( + yield* servers.list, + (row) => + Effect.gen(function* () { + const home = servers.homeOf(row); + const [cli, server] = yield* Effect.all( + [executableOf(row, foundOnce), serverAt(home.path, row.isDefault)], + { + concurrency: "unbounded", + }, + ); + return { + id: row.id, + kind: row.kind, + name: row.name, + isDefault: row.isDefault, + home, + cli, + server, + args: row.args, + env: row.env.map(({ name, sensitive, value }) => + sensitive ? { name, sensitive } : { name, sensitive, value }, + ), + } satisfies ServerStatus; + }), + { concurrency: "unbounded" }, + ); +}); + +// The CLI owns a server's lifecycle; the app only asks it, with the server's variables and in its +// home, which a variable never overrides. +export const service = Effect.fn("Cli.service")(function* (row: Servers.Row, action: CliAction) { + const servers = yield* Servers.Service; + const cli = yield* executableOf(row, found); + if (cli === null || !("version" in cli)) + return yield* new ServersError({ message: "No working codework CLI was found." }); + const env = Object.fromEntries(row.env.map(({ name, value }) => [name, value])); + // `stop` takes no server options, so the arguments go only to what starts a server. + const args = action === "stop" ? [] : Servers.splitArgs(row.args); + const { file, prefix } = row.executable === null ? { file: cli.path, prefix: [] } : split(row.executable); + const { output, code } = yield* run(file, [...prefix, "service", action, ...args], "30 seconds", { + ...env, + CODEWORK_HOME_DIR: servers.homeOf(row).path, + }).pipe(Effect.mapError((error) => new ServersError({ message: `codework service ${action}: ${error.message}` }))); + if (code !== 0) return yield* new ServersError({ message: `codework service ${action} ${reason(output, code)}` }); + if (row.isDefault && action !== "stop") yield* awaitConnection; +}); export * as Cli from "./cli.ts"; diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 5e745db5..5bb996a1 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -1,10 +1,19 @@ import { Effect } from "effect"; import { Cli } from "./cli.ts"; import { Codework } from "./codework.ts"; -import { Contract, ModelsError, type Project } from "@codeworksh/contract"; +import { + type CliAction, + Contract, + ModelsError, + type Project, + type ServerAdd, + type ServerUpdate, +} from "@codeworksh/contract"; import { Desk } from "./desk.ts"; import { Event } from "./event.ts"; +import { Health } from "./health.ts"; import { Kv } from "./kv.ts"; +import { Servers } from "./servers.ts"; import { Workspaces } from "./workspaces.ts"; /** @@ -40,20 +49,30 @@ export const layer = Contract.Api.toLayer( const desk = yield* Desk.handlers; const events = yield* Event.Service; const kv = yield* Kv.Service; + const servers = yield* Servers.Service; + const health = yield* Health.Service; + // A failed call is the first sign the default server went down. + const call = (use: Parameters>[0]) => + Codework.call(use).pipe(Effect.tapError(() => health.poke)); + // A changed default reconnects the app; the status read after waits for it to reach the server. + const changingDefault = (change: Effect.Effect) => + Effect.gen(function* () { + const before = yield* servers.defaultHome; + yield* change; + if ((yield* servers.defaultHome) !== before) yield* Cli.awaitConnection; + }); return Contract.Api.of({ ...workspaces, ...desk, - ...Cli.handlers, "projects.list": Effect.fn("projects.list")(function* () { - const sessions = yield* Codework.call((client) => client("session.list", {})); - return byProject(sessions); + return byProject(yield* call((client) => client("session.list", {}))); }), // The catalog's own errors are kept apart from an unreachable server, so their detail shows. "models.list": Effect.fn("models.list")(function* ({ hostDir }) { - const listed = yield* Codework.call((client) => + const listed = yield* call((client) => client("model.list", hostDir === undefined ? {} : { hostDir }).pipe( Effect.map((list) => ({ list })), Effect.catchTags({ @@ -72,6 +91,36 @@ export const layer = Contract.Api.toLayer( return listed.list; }), + "servers.watch": () => health.changes, + "servers.check": () => health.check, + "servers.health": () => health.settings, + "servers.setHealth": (settings) => health.setSettings(settings), + "servers.add": Effect.fn("servers.add")(function* (input: ServerAdd) { + return yield* servers.add(input).pipe(Effect.tap(() => health.check)); + }), + "servers.update": Effect.fn("servers.update")(function* (input: ServerUpdate) { + yield* changingDefault(servers.update(input)); + yield* health.check; + }), + "servers.remove": Effect.fn("servers.remove")(function* ({ id }: { readonly id: string }) { + yield* servers.remove(id); + yield* health.check; + }), + "servers.setDefault": Effect.fn("servers.setDefault")(function* ({ id }: { readonly id: string }) { + yield* changingDefault(servers.setDefault(id)); + yield* health.check; + }), + "servers.service": Effect.fn("servers.service")(function* ({ + id, + action, + }: { + readonly id: string; + readonly action: CliAction; + }) { + // Checked again whether it worked or not, so the status shows what the CLI left. + yield* Cli.service(yield* servers.get(id), action).pipe(Effect.ensuring(health.check)); + }), + "kv.get": ({ package: pkg, key }) => kv.get(pkg, key), "kv.set": ({ package: pkg, key, value }) => kv.set(pkg, key, value), diff --git a/apps/server/src/health.ts b/apps/server/src/health.ts new file mode 100644 index 00000000..28b975ce --- /dev/null +++ b/apps/server/src/health.ts @@ -0,0 +1,108 @@ +import { Context, Duration, Effect, Layer, Option, Queue, Schema, Semaphore, Stream, SubscriptionRef } from "effect"; +import { DEFAULT_SERVER_HEALTH, ServerHealth, type ServerStatus } from "@codeworksh/contract"; +import { Cli } from "./cli.ts"; +import { Kv } from "./kv.ts"; +import { Servers } from "./servers.ts"; + +// A server started or stopped outside the app (a terminal's `codework service`) shows quickly. +const EVERY = Duration.seconds(5); + +const decodeHealth = Schema.decodeUnknownOption(ServerHealth); +const store = { package: "@codeworksh/server", key: "health" }; + +/** + * The servers' status, held so every reader sees the same one: checked at launch, every `interval`, + * after anything the app changes, and soon after a call to the default server fails. The default + * server's process is also looked for every few seconds, so it going or coming shows at once. + */ +export class Service extends Context.Service< + Service, + { + /** The statuses as last checked, then each change. */ + readonly changes: Stream.Stream>; + /** Checks every server again; done once the new statuses are out. */ + readonly check: Effect.Effect; + /** Checks the default server soon, as after a call to it failed. */ + readonly poke: Effect.Effect; + readonly settings: Effect.Effect; + readonly setSettings: (settings: ServerHealth) => Effect.Effect; + } +>()("@codeworksh/server/health/Service") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const kv = yield* Kv.Service; + const servers = yield* Servers.Service; + const context = yield* Effect.context>(); + const current = yield* SubscriptionRef.make | undefined>(undefined); + const lock = yield* Semaphore.make(1); + const pokes = yield* Queue.sliding(1); + const settingsChanged = yield* Queue.sliding(1); + + const check = lock.withPermits(1)( + Cli.statuses.pipe( + Effect.provideContext(context), + Effect.flatMap((statuses) => SubscriptionRef.set(current, statuses)), + ), + ); + + // The default server alone is cheap to ask; only a change is worth a full check. Unless `asked`, + // a running one is only looked for, without a call. + const checkDefault = (asked: boolean) => + Effect.gen(function* () { + const before = (yield* SubscriptionRef.get(current))?.find((status) => status.isDefault); + if (before === undefined) return; + const home = servers.homeOf(yield* servers.get(before.id)).path; + if (!asked && before.server._tag === "Running") { + const live = yield* Cli.liveAt(home).pipe(Effect.provideContext(context)); + if (Option.isNone(live)) yield* check; + return; + } + const now = yield* Cli.serverAt(home, true).pipe(Effect.provideContext(context)); + if (now._tag !== before.server._tag) yield* check; + }).pipe(Effect.ignore); + + const settings = kv + .get(store.package, store.key) + .pipe( + Effect.map((saved) => Option.getOrElse(Option.flatMap(saved, decodeHealth), () => DEFAULT_SERVER_HEALTH)), + ); + + yield* check.pipe(Effect.forkScoped); + + // Every interval; a new interval starts counting at once, and 0 waits for one. + yield* Effect.forever( + Effect.gen(function* () { + const { interval } = yield* settings; + const changed = Queue.take(settingsChanged).pipe(Effect.as(false)); + const elapsed = yield* interval === 0 + ? changed + : Effect.raceFirst(Effect.sleep(Duration.seconds(interval)).pipe(Effect.as(true)), changed); + if (elapsed) yield* check; + }), + ).pipe(Effect.forkScoped); + + yield* Effect.forever( + Effect.gen(function* () { + const poked = yield* Queue.take(pokes).pipe(Effect.timeoutOption(EVERY)); + yield* checkDefault(Option.isSome(poked)); + }), + ).pipe(Effect.forkScoped); + + return Service.of({ + changes: SubscriptionRef.changes(current).pipe( + Stream.filter((statuses): statuses is ReadonlyArray => statuses !== undefined), + ), + check, + poke: Queue.offer(pokes, undefined).pipe(Effect.asVoid), + settings, + setSettings: Effect.fn("Health.setSettings")(function* (next) { + yield* kv.set(store.package, store.key, next); + yield* Queue.offer(settingsChanged, undefined); + }), + }); + }), +); + +export * as Health from "./health.ts"; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9c9d2926..b13f6d45 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -10,6 +10,7 @@ import { Contract } from "@codeworksh/contract"; import { Db } from "./db.ts"; import { Event } from "./event.ts"; import { Handlers } from "./handlers.ts"; +import { Health } from "./health.ts"; import { Kv } from "./kv.ts"; import { Servers } from "./servers.ts"; import { Widgets } from "./widgets.ts"; @@ -72,7 +73,8 @@ export const layer = Layer.mergeAll( Widgets.layer, exitWithParent, ).pipe( - Layer.provide(Layer.mergeAll(Event.layer, Kv.layer, Codework.layer)), + // The servers' status is checked from launch, so the first page already has it. + Layer.provide(Health.layer.pipe(Layer.provideMerge(Layer.mergeAll(Event.layer, Kv.layer, Codework.layer)))), // The app's servers decide which one its connection goes to, and the handlers manage them too. Layer.provideMerge(Servers.layer), Layer.provideMerge(announceReady), diff --git a/apps/server/test/__artifacts__/health.json b/apps/server/test/__artifacts__/health.json new file mode 100644 index 00000000..2edb3a47 --- /dev/null +++ b/apps/server/test/__artifacts__/health.json @@ -0,0 +1,11 @@ +{ + "first": "Stopped", + "registered": "Unreachable", + "gone": "Stopped", + "health": { + "interval": 300 + }, + "changed": { + "interval": 60 + } +} diff --git a/apps/server/test/health.test.ts b/apps/server/test/health.test.ts new file mode 100644 index 00000000..8b062997 --- /dev/null +++ b/apps/server/test/health.test.ts @@ -0,0 +1,91 @@ +/* @effect-diagnostics nodeBuiltinImport:off -- the suite spawns the real server in a temp dir. */ +import { Effect, Layer, Option, Stream } from "effect"; +import { RpcClient, RpcSerialization } from "effect/rpc"; +import { Socket } from "effect/socket"; +import { type ChildProcess, spawn } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, expect, it } from "vite-plus/test"; +import { Contract, type ServerStatus } from "@codeworksh/contract"; + +const entry = fileURLToPath(new URL("../src/index.ts", import.meta.url)); +const home = mkdtempSync(join(tmpdir(), "codework-health-")); +let server: ChildProcess; +let port: number; + +// The home names no CodeWork service until the test writes one; the install folder is empty too. +beforeAll(async () => { + server = spawn(process.execPath, [entry], { + env: { + ...process.env, + CODEWORK_DATABASE: join(home, "app.db"), + CODEWORK_HOME_DIR: home, + CODEWORK_INSTALL_DIR: join(home, "bin"), + }, + stdio: ["ignore", "pipe", "inherit"], + }); + port = await new Promise((resolve, reject) => { + server.once("exit", (code) => reject(new Error(`server exited before ready (code ${code})`))); + createInterface({ input: server.stdout! }).on("line", (line) => { + const message = JSON.parse(line) as { type?: string; port?: number }; + if (message.type === "ready" && message.port !== undefined) resolve(message.port); + }); + }); +}, 20_000); + +afterAll(async () => { + const exited = new Promise((resolve) => server.once("exit", resolve)); + server.kill("SIGKILL"); + await exited; + rmSync(home, { recursive: true, force: true }); +}); + +const protocol = () => + RpcClient.layerProtocolSocket().pipe( + Layer.provide(Socket.layerWebSocket(`ws://127.0.0.1:${port}/rpc`)), + Layer.provide(Socket.layerWebSocketConstructorGlobal), + Layer.provide(RpcSerialization.layerNdjson), + ); + +const defaultTag = (statuses: ReadonlyArray) => statuses.find((status) => status.isDefault)?.server._tag; + +it("keeps the servers' status and follows the default server going up and down", async () => { + const steps = await Effect.gen(function* () { + const rpc = yield* RpcClient.make(Contract.Api); + const watch = rpc["servers.watch"](undefined); + // The next status whose default server reads `tag`; every reader is sent the same one. + const until = (tag: string) => + watch.pipe( + Stream.map(defaultTag), + Stream.filter((seen) => seen === tag), + Stream.runHead, + Effect.map(Option.getOrUndefined), + Effect.timeout("20 seconds"), + ); + + const first = yield* until("Stopped"); + + // A registration whose process lives (this test) but whose server never answers. + writeFileSync( + join(home, "service.json"), + JSON.stringify({ pid: process.pid, version: "0.0.0", url: "ws://127.0.0.1:9/rpc", port: 9 }), + ); + const registered = yield* until("Unreachable"); + + rmSync(join(home, "service.json")); + const gone = yield* until("Stopped"); + + const health = yield* rpc["servers.health"](undefined); + yield* rpc["servers.setHealth"]({ interval: 60 }); + const changed = yield* rpc["servers.health"](undefined); + + return { first, registered, gone, health, changed }; + }).pipe(Effect.scoped, Effect.provide(protocol()), Effect.runPromise); + + expect(steps.registered).toBe("Unreachable"); + expect(steps.changed).toEqual({ interval: 60 }); + await expect(`${JSON.stringify(steps, null, "\t")}\n`).toMatchFileSnapshot("./__artifacts__/health.json"); +}, 60_000); diff --git a/apps/web/index.html b/apps/web/index.html index e293351c..f37396af 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -31,9 +31,14 @@ sans-serif; } + /* Over the app while it mounts behind, then faded away (src/splash.ts) so the swap reads as one. */ #boot-shell { + position: fixed; + inset: 0; + z-index: 100; + background: inherit; + transition: opacity 280ms cubic-bezier(0.23, 1, 0.32, 1); display: flex; - height: 100%; flex-direction: column; align-items: center; justify-content: center; @@ -46,6 +51,23 @@ width: 64px; height: 64px; border-radius: 14px; + transition: transform 280ms cubic-bezier(0.23, 1, 0.32, 1); + } + + #boot-shell.done { + opacity: 0; + pointer-events: none; + } + + #boot-shell.done img { + transform: scale(1.08); + } + + @media (prefers-reduced-motion: reduce) { + #boot-shell, + #boot-shell img { + transition: none; + } } #boot-shell button { @@ -61,11 +83,10 @@ - -
-
- CodeWork -
+
+ +
+ CodeWork
diff --git a/apps/web/src/components/banner.tsx b/apps/web/src/components/banner.tsx index 693eb4bb..ee321dfc 100644 --- a/apps/web/src/components/banner.tsx +++ b/apps/web/src/components/banner.tsx @@ -1,14 +1,13 @@ -import { CircleAlert, Clock, type LucideIcon, TriangleAlert, X } from "lucide-react"; +import { CircleAlert, Clock, type LucideIcon, X } from "lucide-react"; /** * Something the reader should know before writing more: a short title, and a line only when it adds - * a fact. `expired` is a state the session is in, not a fault, so it reads neutral rather than red; - * `warning` is a state outside the session (its server is down) that one action may fix. + * a fact. `expired` is a state the session is in, not a fault, so it reads neutral rather than red. */ export interface Notice { readonly title: string; readonly description?: string; - readonly tone: "error" | "warning" | "expired"; + readonly tone: "error" | "expired"; readonly action?: NoticeAction; } @@ -26,11 +25,6 @@ const tones: Record - Server.query("models.list", hostDir === undefined ? {} : { hostDir }), + Server.query("models.list", hostDir === undefined ? {} : { hostDir }, { reactivityKeys: codeworkKey }), ); /** - * Why the models for `hostDir` can't be listed, as the composer's notice; undefined while they can. - * A down server is named from its own status, which is checked again when it goes down and comes - * back. While it fails the list is asked for again every few seconds, so the notice clears itself - * and the pickers come back once whatever was wrong is fixed, here or elsewhere. + * A broken settings file, as the composer's notice; it names the file, which the reader fixes. Any + * other failure is the server's, which the pickers' place already points to. */ export function useModelsNotice(hostDir: string | undefined): Notice | undefined { const result = useAtomValue(modelsAtom(hostDir)); - const refresh = useAtomRefresh(modelsAtom(hostDir)); - const servers = useAtomValue(serversAtom); - const refreshServers = useAtomRefresh(serversAtom); - const fix = useFix(refresh); const error = AsyncResult.isFailure(result) ? Option.getOrUndefined(Cause.findErrorOption(result.cause)) : undefined; - const failed = AsyncResult.isFailure(result); - const down = error?._tag === "Server.CodeworkUnavailable"; - - useEffect(() => { - if (!failed || result.waiting) return; - const timer = setTimeout(refresh, 5_000); - return () => clearTimeout(timer); - }, [failed, result, refresh]); - // The server's status costs a login shell and the CLI to read, so only on the way down and back up. - const wasDown = useRef(false); - useEffect(() => { - if (down !== wasDown.current) refreshServers(); - wasDown.current = down; - }, [down, refreshServers]); - - if (!failed) return undefined; - const retry = { label: "Try Again", icon: RotateCw, disabled: result.waiting, onClick: refresh }; - if (error?._tag === "Server.ModelsError") { - if (error.source === "catalog") - return { - title: "Model Catalog Missing or Unavailable", - description: error.message, - tone: "error", - action: retry, - }; - const { reveal } = window.desktopBridge ?? {}; - return { - title: "Settings Error", - description: error.message, - tone: "error", - ...(reveal === undefined - ? {} - : { action: { label: "Reveal", icon: FileSearch, onClick: () => void reveal(error.path) } }), - }; - } - const server = down && AsyncResult.isSuccess(servers) ? servers.value.find((one) => one.isDefault) : undefined; - const problem = server === undefined ? undefined : problemOf(server); - if (server === undefined || problem === undefined) - return { title: "Couldn't Load Models", tone: "error", action: retry }; + if (error?._tag !== "Server.ModelsError" || error.source !== "settings") return undefined; + const { reveal } = window.desktopBridge ?? {}; return { - title: problem.title, - ...((fix.error ?? problem.detail) === undefined ? {} : { description: fix.error ?? problem.detail }), - tone: "warning", - action: { - label: fix.label(problem), - icon: fixIcons[problem.fix], - disabled: fix.running, - onClick: () => fix.run(server, problem), - }, + title: "Settings Error", + description: error.message, + tone: "error", + ...(reveal === undefined + ? {} + : { action: { label: "Reveal", icon: FileSearch, onClick: () => void reveal(error.path) } }), }; } @@ -143,15 +97,23 @@ const lookup =

(providers: ReadonlyArray

, ref: Model * right, and a search across all of them. `value` undefined means settings decide; the trigger * then shows the model they pick, muted when this server has no credentials for it. */ -export function ModelPicker({ - hostDir, - value, - onChange, -}: { +interface ModelPickerProps { readonly hostDir: string | undefined; readonly value: ModelRef | undefined; readonly onChange: (model: ModelRef) => void; -}) { +} + +/** The model picker, or, while the default server has a problem, the way to the provider settings. */ +export function ModelPicker(props: ModelPickerProps) { + const problem = useAtomValue(problemAtom); + return problem === undefined ? ( + + ) : ( + + ); +} + +function Models({ hostDir, value, onChange }: ModelPickerProps) { const result = useAtomValue(modelsAtom(hostDir)); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); @@ -382,17 +344,19 @@ const clamp = (level: ThinkingLevel, levels: ReadonlyArray) => { * settings decide. Always in the row: for a model that does not reason, or one the list cannot * call, it reads "Reasoning" and does not open. */ -export function ThinkingPicker({ - hostDir, - model, - value, - onChange, -}: { +interface ThinkingPickerProps { readonly hostDir: string | undefined; readonly model: ModelRef | undefined; readonly value: ThinkingLevel | undefined; readonly onChange: (level: ThinkingLevel) => void; -}) { +} + +/** The reasoning picker; gone while the default server has a problem, as the model picker is. */ +export function ThinkingPicker(props: ThinkingPickerProps) { + return useAtomValue(problemAtom) === undefined ? : null; +} + +function Thinking({ hostDir, model, value, onChange }: ThinkingPickerProps) { const result = useAtomValue(modelsAtom(hostDir)); const list = AsyncResult.isSuccess(result) ? result.value : undefined; const current = model ?? list?.default; diff --git a/apps/web/src/components/offline.tsx b/apps/web/src/components/offline.tsx new file mode 100644 index 00000000..d7cb1554 --- /dev/null +++ b/apps/web/src/components/offline.tsx @@ -0,0 +1,27 @@ +import { useNavigate } from "@tanstack/react-router"; +import { CircleAlert } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import type { Problem } from "../kernel/servers"; + +/** + * Where the default server's problem shows, in the sidebar and the composer alike: one way to the + * provider settings, which say what is wrong and fix it. The problem reads on hover. + */ +export function OpenProviderSettings({ problem, className }: { problem: Problem; className?: string }) { + const navigate = useNavigate(); + return ( + + ); +} diff --git a/apps/web/src/kernel/servers.ts b/apps/web/src/kernel/servers.ts new file mode 100644 index 00000000..ada99e74 --- /dev/null +++ b/apps/web/src/kernel/servers.ts @@ -0,0 +1,78 @@ +import type { ServerStatus } from "@codeworksh/contract"; +import { Effect, Schedule, Stream } from "effect"; +import { AsyncResult, Atom, Reactivity } from "effect/reactivity"; + +import { Server } from "./rpc"; + +/** Queries answered by the default CodeWork server; they load again once it is back. */ +export const codeworkKey = ["codework"]; + +/** Why the default server can't serve the app. */ +export interface Problem { + readonly title: string; + /** A sentence naming the server. */ + readonly line: string; +} + +/** The default server's problem from its status; undefined while the app can work with it. */ +export function problemOf(status: ServerStatus): Problem | undefined { + const { cli, server } = status; + if (cli === null) + return { + title: "CodeWork Isn't Installed", + line: "Install it, or point to it, to see your projects and sessions.", + }; + if ("failure" in cli) + return { + title: "CodeWork Won't Start", + line: `The codework executable for ${status.name} fails to run.`, + }; + switch (server._tag) { + case "Stopped": + return { + title: "Server Not Running", + line: `${status.name} isn't running, so your projects can't load.`, + }; + case "Unreachable": + return { + title: "Server Not Answering", + line: `${status.name} is registered but doesn't reply.`, + }; + case "Incompatible": + return { + title: "Incompatible Server Version", + line: `${status.name} runs codework v${server.version}, which this app can't read.`, + }; + case "Running": + return undefined; + } +} + +/** + * Every server's status, as the app server last checked it, kept current as it checks again. When + * the default server becomes usable, whatever failed while it was not loads again. + */ +export const serversAtom = Server.runtime + .atom( + Stream.suspend(() => { + let wasUsable: boolean | undefined; + return Stream.unwrap(Server.use((client) => Effect.succeed(client("servers.watch", undefined)))).pipe( + Stream.tap((statuses) => { + const now = statuses.some((status) => status.isDefault && problemOf(status) === undefined); + const recovered = wasUsable === false && now; + wasUsable = now; + return recovered ? Reactivity.invalidate(codeworkKey) : Effect.void; + }), + // The app server is local, so a dropped stream is only a moment's gap. + Stream.retry(Schedule.spaced("1 second")), + ); + }), + ) + .pipe(Atom.keepAlive); + +/** The default server's problem once its status is known; undefined until then, and while there is none. */ +export const problemAtom = Atom.make((get) => { + const result = get(serversAtom); + const status = AsyncResult.isSuccess(result) ? result.value.find((candidate) => candidate.isDefault) : undefined; + return status === undefined ? undefined : problemOf(status); +}); diff --git a/apps/web/src/landing/offline.tsx b/apps/web/src/landing/offline.tsx deleted file mode 100644 index 6b5edf4b..00000000 --- a/apps/web/src/landing/offline.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; -import { useNavigate } from "@tanstack/react-router"; -import { AsyncResult } from "effect/reactivity"; -import { RotateCw } from "lucide-react"; -import type { ReactNode } from "react"; - -import { serversAtom } from "../settings/servers/local"; -import { fixIcons, problemOf, useFix } from "../settings/servers/problem"; -import { projectsAtom } from "../widgets/session/projects"; -import { Empty, EmptyAction } from "./empty"; - -/** - * What the sidebar shows when sessions cannot load, from the default server's own state: what is - * wrong in a few words, and the one thing that fixes it, so the list loads in place once it does. - */ -export function ServerOffline() { - const servers = useAtomValue(serversAtom); - const refreshProjects = useAtomRefresh(projectsAtom); - const navigate = useNavigate(); - const fix = useFix(refreshProjects); - const server = AsyncResult.isSuccess(servers) ? servers.value.find((candidate) => candidate.isDefault) : undefined; - const settingsLink = ( - - ); - - if (server === undefined) - return ( - - {AsyncResult.isFailure(servers) && settingsLink} - - ); - - const problem = problemOf(server); - if (problem === undefined) - return ( - - } - onClick={() => refreshProjects()} - > - Try Again - - - ); - - const Icon = fixIcons[problem.fix]; - return ( - - } - disabled={fix.running} - onClick={() => fix.run(server, problem)} - > - {fix.label(problem)} - - {fix.error !== null && } - {problem.fix !== "settings" && settingsLink} - - ); -} - -function Message({ title, line, children }: { title: string; line: string; children?: ReactNode }) { - return ( - - {title} - {line} - {children} - - ); -} - -const Failure = ({ message }: { message: string }) => ( - {message} -); diff --git a/apps/web/src/landing/sidebar.tsx b/apps/web/src/landing/sidebar.tsx index 4979a5e7..c39e7ba1 100644 --- a/apps/web/src/landing/sidebar.tsx +++ b/apps/web/src/landing/sidebar.tsx @@ -1,6 +1,5 @@ import { useAtom, useAtomValue } from "@effect/atom-react"; import { useNavigate, useParams } from "@tanstack/react-router"; -import { Option } from "effect"; import { AsyncResult } from "effect/reactivity"; import { Plus, Search, SquarePen } from "lucide-react"; import { type MouseEvent, useState } from "react"; @@ -14,8 +13,9 @@ import { deskAtom } from "../wm/instances"; import { useOpenElsewhere } from "../wm/open"; import { ago } from "../widgets/time"; import { Monogram } from "@/components/monogram"; +import { OpenProviderSettings } from "@/components/offline"; +import { problemAtom } from "../kernel/servers"; import { Empty, EmptyAction } from "./empty"; -import { ServerOffline } from "./offline"; import { placeAtom, usePickFolder } from "./place"; /** @@ -24,6 +24,7 @@ import { placeAtom, usePickFolder } from "./place"; */ export function SessionsSidebar() { const result = useAtomValue(projectsAtom); + const problem = useAtomValue(problemAtom); const [query, setQuery] = useState(""); const openElsewhere = useOpenElsewhere(); const [place, setPlace] = useAtom(placeAtom); @@ -88,13 +89,13 @@ export function SessionsSidebar() {

- {AsyncResult.isFailure(result) ? ( - // The CodeWork server is the usual reason, and the sidebar can say which state it is in and fix it. - Option.exists(AsyncResult.error(result), (error) => error._tag === "Server.CodeworkUnavailable") ? ( - - ) : ( -

Could not load sessions.

- ) + {problem !== undefined ? ( + + ) : AsyncResult.isFailure(result) ? ( +

Could not load sessions.

) : AsyncResult.isSuccess(result) && sessions.length === 0 ? ( needle !== "" ? (

No sessions match “{query}”.

diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 2e76e549..d9fa1ee1 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -12,8 +12,8 @@ import "./style.css"; const history = isElectron ? createHashHistory() : createBrowserHistory(); const router = getRouter(history); -// The splash in index.html stays up until the first route's split chunks have loaded, so the app -// mounts whole instead of flashing an empty shell. +// The app mounts once the first route's split chunks have loaded, so it is whole when the splash +// over it fades (see Splash). export const startup = router.load().then(() => { ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 63d482f7..f7abeb33 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,21 +1,17 @@ -import { useAtomMount } from "@effect/atom-react"; import { createRootRoute, Outlet, useLocation } from "@tanstack/react-router"; import type { CSSProperties } from "react"; import { SidebarProvider } from "@/components/ui/sidebar"; import { SessionsSidebar } from "../landing/sidebar"; import { SettingsSidebar } from "../settings/layout"; -import { serversAtom } from "../settings/servers/local"; import { Navigation } from "../shell/navigation"; import { NavigationControls } from "../shell/titlebar"; import { Palette } from "../shell/palette"; +import { Splash } from "../splash"; // The sessions sidebar wraps every page but settings, which has its own; its open state carries over. function Root() { const inSettings = useLocation({ select: (location) => location.pathname.startsWith("/settings") }); - // Checking the servers spawns a login shell and the CLI, so it runs from launch and stays held: - // Settings › Servers and the offline sidebar open on the last status instead of loading. - useAtomMount(serversAtom); return ( + ); } diff --git a/apps/web/src/settings/blocks.tsx b/apps/web/src/settings/blocks.tsx index 5b4b575e..b0c92e5b 100644 --- a/apps/web/src/settings/blocks.tsx +++ b/apps/web/src/settings/blocks.tsx @@ -49,7 +49,7 @@ export function Row({ description, children, }: { - title: string; + title: ReactNode; description?: ReactNode; children?: ReactNode; }) { diff --git a/apps/web/src/settings/servers/add.tsx b/apps/web/src/settings/servers/add.tsx index e154d69b..e64189d2 100644 --- a/apps/web/src/settings/servers/add.tsx +++ b/apps/web/src/settings/servers/add.tsx @@ -15,7 +15,7 @@ import { Input } from "@/components/ui/input"; import { messageOf } from "@/lib/utils"; import { Server } from "../../kernel/rpc"; import { Code, quietField } from "../blocks"; -import { Badge, Mark, serversKey } from "./local"; +import { Badge, Mark } from "./local"; const addAtom = Server.mutation("servers.add"); @@ -52,7 +52,6 @@ export function AddServer({ setError(null); add({ payload: { name, executable: executable.trim() || null, home, args }, - reactivityKeys: serversKey, }) .then((id) => { onAdded(id); diff --git a/apps/web/src/settings/servers/local.tsx b/apps/web/src/settings/servers/local.tsx index 205cb4eb..68937b05 100644 --- a/apps/web/src/settings/servers/local.tsx +++ b/apps/web/src/settings/servers/local.tsx @@ -21,8 +21,6 @@ import { Server } from "../../kernel/rpc"; import { Code, quietField as field, SavingInput } from "../blocks"; import { Variables } from "./variables"; -export const serversKey = ["servers"]; -export const serversAtom = Server.query("servers.list", undefined, { reactivityKeys: serversKey }); const serviceAtom = Server.mutation("servers.service"); const updateAtom = Server.mutation("servers.update"); const removeAtom = Server.mutation("servers.remove"); @@ -52,17 +50,17 @@ const Version = ({ version }: { version: string }) => ( ); /** - * Starts, restarts or stops a server through its CLI, as the Servers page and the sidebar both do. - * `act` resolves to whether it worked; the server list refreshes either way. + * Starts, restarts or stops a server through its CLI. + * `act` resolves to whether it worked; the app server checks the servers again either way. */ -export function useServerAction() { +function useServerAction() { const service = useAtomSet(serviceAtom, { mode: "promise" }); const [running, setRunning] = useState(null); const [error, setError] = useState(null); const act = (id: string, action: CliAction) => { setError(null); setRunning(action); - return service({ payload: { id, action }, reactivityKeys: serversKey }) + return service({ payload: { id, action } }) .then( () => true, (cause: unknown) => { @@ -149,7 +147,7 @@ export function LocalDetail({ status }: { status: ServerStatus }) { const usable = cli !== null && "version" in cli; const configure = (patch: Omit) => - update({ payload: { id, ...patch }, reactivityKeys: serversKey }).then( + update({ payload: { id, ...patch } }).then( () => undefined, (cause: unknown) => messageOf(cause), ); @@ -293,7 +291,7 @@ function MakeDefault({ status, onError }: { status: ServerStatus; onError: (mess title="Connect the app through this server" onClick={() => { setSwitching(true); - setDefault({ payload: { id: status.id }, reactivityKeys: serversKey }) + setDefault({ payload: { id: status.id } }) .catch((cause: unknown) => onError(messageOf(cause))) .finally(() => setSwitching(false)); }} @@ -338,9 +336,7 @@ function ServerActions({ status, onError }: { status: ServerStatus; onError: (me Cancel - run(() => remove({ payload: { id: status.id }, reactivityKeys: serversKey }))} - > + run(() => remove({ payload: { id: status.id } }))}> Remove diff --git a/apps/web/src/settings/servers/problem.ts b/apps/web/src/settings/servers/problem.ts deleted file mode 100644 index c8cd64ba..00000000 --- a/apps/web/src/settings/servers/problem.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { ServerStatus } from "@codeworksh/contract"; -import { useNavigate } from "@tanstack/react-router"; -import { type LucideIcon, Play, RotateCw, Settings } from "lucide-react"; - -import { useServerAction } from "./local"; - -/** Why the default server can't serve the app, and the one thing that fixes it. */ -export interface Problem { - readonly title: string; - /** A sentence naming the server, for where there is room. */ - readonly line: string; - /** The one fact worth a second line where there is little room. */ - readonly detail?: string; - readonly fix: "start" | "restart" | "settings"; - readonly label: string; -} - -/** The default server's problem from its status; undefined while it runs. */ -export function problemOf(status: ServerStatus): Problem | undefined { - const { cli, server } = status; - if (cli === null) - return { - title: "CodeWork Isn't Installed", - line: "Install it, or point to it, to see your projects and sessions.", - fix: "settings", - label: "Set Up", - }; - if ("failure" in cli) - return { - title: "CodeWork Won't Start", - line: `The codework executable for ${status.name} fails to run.`, - fix: "settings", - label: "Server Settings", - }; - switch (server._tag) { - case "Stopped": - return { - title: "Server Not Running", - line: `${status.name} isn't running, so your projects can't load.`, - fix: "start", - label: "Start Server", - }; - case "Unreachable": - return { - title: "Server Not Answering", - line: `${status.name} is registered but doesn't reply.`, - fix: "restart", - label: "Restart", - }; - case "Incompatible": - return { - title: "Incompatible Server Version", - line: `${status.name} runs codework v${server.version}, which this app can't read.`, - detail: `v${server.version}`, - fix: "settings", - label: "Server Settings", - }; - case "Running": - return undefined; - } -} - -export const fixIcons: Record = { start: Play, restart: RotateCw, settings: Settings }; - -/** - * Runs a problem's fix: starts or restarts the server, or opens its settings. `onFixed` runs once - * the server is up, so the caller can load again what failed. - */ -export function useFix(onFixed: () => void) { - const navigate = useNavigate(); - const { running, error, act } = useServerAction(); - const run = (status: ServerStatus, problem: Problem) => { - if (problem.fix === "settings") return void navigate({ to: "/settings/servers" }); - void act(status.id, problem.fix).then((done) => done && onFixed()); - }; - const label = (problem: Problem) => - running === "start" ? "Starting…" : running === "restart" ? "Restarting…" : problem.label; - return { running: running !== null, error, run, label }; -} diff --git a/apps/web/src/settings/servers/servers.tsx b/apps/web/src/settings/servers/servers.tsx index bbf753ba..f90718ed 100644 --- a/apps/web/src/settings/servers/servers.tsx +++ b/apps/web/src/settings/servers/servers.tsx @@ -1,23 +1,32 @@ -import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; -import { Plus, RefreshCw } from "lucide-react"; +import { useAtom, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { DEFAULT_SERVER_HEALTH } from "@codeworksh/contract"; +import { Minus, Plus, RefreshCw, Undo2 } from "lucide-react"; import { AsyncResult } from "effect/reactivity"; import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; -import { Page } from "../blocks"; +import { messageOf } from "@/lib/utils"; +import { Server } from "../../kernel/rpc"; +import { serversAtom } from "../../kernel/servers"; +import { Group, Page, quietField, Row, SavingInput, Section } from "../blocks"; import { AddServer } from "./add"; -import { LocalDetail, LocalItem, serversAtom } from "./local"; +import { LocalDetail, LocalItem } from "./local"; + +const checkAtom = Server.mutation("servers.check"); +const healthKey = ["health"]; +const healthAtom = Server.query("servers.health", undefined, { reactivityKeys: healthKey }); +const setHealthAtom = Server.mutation("servers.setHealth"); /** The CodeWork servers the app can work with, listed beside the one selected; the built-in one is always there. */ export function ServersSettings() { const result = useAtomValue(serversAtom); - const refresh = useAtomRefresh(serversAtom); + const [check, runCheck] = useAtom(checkAtom); + const checking = check.waiting; const servers = AsyncResult.isSuccess(result) ? result.value : undefined; // The status is held from launch (see the root route), so opening the page shows it at once and // checks again behind it. - useEffect(() => { - if (!result.waiting) refresh(); - }, []); + useEffect(() => runCheck({ payload: undefined }), []); const [selectedId, setSelectedId] = useState(null); const [adding, setAdding] = useState(false); // The default until one is picked, and again when the picked one is gone. @@ -34,12 +43,12 @@ export function ServersSettings() { } > @@ -94,10 +103,89 @@ export function ServersSettings() { +
+ + + +
); } +const STEP = 30; + +/** How often the servers are checked again in the background, in seconds; 0 turns it off. */ +function HealthInterval() { + const result = useAtomValue(healthAtom); + const save = useAtomSet(setHealthAtom, { mode: "promise" }); + const [error, setError] = useState(null); + const interval = AsyncResult.isSuccess(result) ? result.value.interval : undefined; + const set = (seconds: number) => { + setError(null); + save({ payload: { interval: seconds }, reactivityKeys: healthKey }).catch((cause: unknown) => + setError(messageOf(cause)), + ); + }; + const commit = (typed: string) => { + const seconds = Number(typed); + if (!Number.isInteger(seconds) || seconds < 0) return setError("Use whole seconds, or 0 to turn it off."); + set(seconds); + }; + return ( + + Health check interval + {/* Always there and only shown off the default, so the row never changes height. */} + + + } + description={error ?? "Refresh provider status and versions in the background. Set to 0 to disable."} + > + {interval !== undefined && ( +
+
+ + + +
+ seconds +
+ )} +
+ ); +} + /** "Checked 2m ago", counted from when this status arrived and kept current while it shows. */ function Checked({ at }: { at: object | undefined }) { const [since, setSince] = useState(() => Date.now()); diff --git a/apps/web/src/splash.tsx b/apps/web/src/splash.tsx new file mode 100644 index 00000000..70136f4c --- /dev/null +++ b/apps/web/src/splash.tsx @@ -0,0 +1,32 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useEffect } from "react"; + +import { serversAtom } from "./kernel/servers"; + +// A status that is slow to come is shown when it comes; the app does not wait longer for it. +const LONGEST = 2_000; + +/** Fades the splash in index.html away; it is gone once the fade ends. */ +function reveal() { + const shell = document.getElementById("boot-shell"); + if (shell === null || shell.classList.contains("done")) return; + shell.classList.add("done"); + const remove = () => shell.remove(); + shell.addEventListener("transitionend", remove, { once: true }); + // No transition runs with reduced motion, nor in a window that is hidden. + setTimeout(remove, 400); +} + +/** + * Keeps the splash up over the mounted app until the servers' status first arrives, so the first + * frame shown already says whether the default server works. Reading the status starts it. + */ +export function Splash() { + const known = useAtomValue(serversAtom)._tag !== "Initial"; + useEffect(() => { + if (known) return reveal(); + const timer = setTimeout(reveal, LONGEST); + return () => clearTimeout(timer); + }, [known]); + return null; +} diff --git a/apps/web/src/widgets/session/projects.ts b/apps/web/src/widgets/session/projects.ts index a9f6af5c..4216619a 100644 --- a/apps/web/src/widgets/session/projects.ts +++ b/apps/web/src/widgets/session/projects.ts @@ -2,9 +2,10 @@ import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/reactivity"; import { Server } from "../../kernel/rpc"; +import { codeworkKey } from "../../kernel/servers"; /** Every project with its sessions, as the sidebar lists them; refresh it once a new session exists. */ -export const projectsAtom = Server.query("projects.list", undefined); +export const projectsAtom = Server.query("projects.list", undefined, { reactivityKeys: codeworkKey }); /** What the list already says about a session: its title and project name, before it is loaded. */ export function useListedSession(id: string) {