From 9e4bb3495038f4d14a5b814809e0e48a09705d48 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sun, 18 Oct 2026 10:26:15 +0530 Subject: [PATCH 1/6] feat(desktop): composer notice when models can't be listed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer now says why models can't be listed and offers the one fix, and the model and reasoning pickers read "Unavailable", disabled, until the list loads again. A down server is named from its own status (not running, not answering, incompatible version, not installed); a broken settings file shows its error with Reveal; the catalog offers Try Again. The list is asked for again every few seconds while it fails, so the notice clears itself once the cause is fixed. - ModelsError carries the source and path of the broken file. - desktopBridge.reveal shows a file in Finder. - The sidebar's offline message and the composer share problem.ts. - Server status loads at launch and stays held, so Settings › Servers opens without a skeleton and rechecks behind it. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/ipc.ts | 2 + apps/desktop/src/main.ts | 5 +- apps/desktop/src/preload.ts | 3 +- apps/server/src/contract.ts | 4 +- apps/server/src/handlers.ts | 12 ++- apps/web/src/components/banner.tsx | 98 +++++++++++++------ apps/web/src/components/model.tsx | 97 +++++++++++++++---- apps/web/src/env.ts | 2 + apps/web/src/landing/landing.tsx | 5 +- apps/web/src/landing/offline.tsx | 109 ++++++---------------- apps/web/src/routes/__root.tsx | 5 + apps/web/src/settings/servers/problem.ts | 79 ++++++++++++++++ apps/web/src/settings/servers/servers.tsx | 5 + apps/web/src/widgets/session/view.tsx | 27 +++--- 14 files changed, 305 insertions(+), 148 deletions(-) create mode 100644 apps/web/src/settings/servers/problem.ts diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index b6827142..74e04584 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -4,3 +4,5 @@ export const SERVER_CHANNEL = "codework:server"; export const HOME_CHANNEL = "codework:home"; /** Invoke: opens the native folder dialog (Finder on macOS); resolves to the chosen path or null. */ export const PICK_FOLDER_CHANNEL = "codework:pick-folder"; +/** Invoke: shows a file in Finder (Explorer on Windows), selected. */ +export const REVEAL_CHANNEL = "codework:reveal"; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 61b85477..aca2eeb2 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,7 +1,7 @@ -import { app, BrowserWindow, dialog, ipcMain, net, protocol } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, net, protocol, shell } from "electron"; import { readFile, stat } from "node:fs/promises"; import path from "node:path"; -import { HOME_CHANNEL, PICK_FOLDER_CHANNEL } from "./ipc.ts"; +import { HOME_CHANNEL, PICK_FOLDER_CHANNEL, REVEAL_CHANNEL } from "./ipc.ts"; import { startServer } from "./server.ts"; const SCHEME = app.isPackaged ? "codework" : "codework-dev"; @@ -146,6 +146,7 @@ void app.whenReady().then(async () => { const result = await (window === null ? dialog.showOpenDialog(options) : dialog.showOpenDialog(window, options)); return result.canceled ? null : (result.filePaths[0] ?? null); }); + ipcMain.handle(REVEAL_CHANNEL, (_event, file: string) => shell.showItemInFolder(file)); await startServer(); createMainWindow(); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 5dcb7e75..8fec9cb6 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer } from "electron"; -import { HOME_CHANNEL, PICK_FOLDER_CHANNEL, SERVER_CHANNEL } from "./ipc.ts"; +import { HOME_CHANNEL, PICK_FOLDER_CHANNEL, REVEAL_CHANNEL, SERVER_CHANNEL } from "./ipc.ts"; contextBridge.exposeInMainWorld("desktopBridge", { platform: process.platform, @@ -7,4 +7,5 @@ contextBridge.exposeInMainWorld("desktopBridge", { server: ipcRenderer.sendSync(SERVER_CHANNEL) as string, home: ipcRenderer.sendSync(HOME_CHANNEL) as string, pickFolder: (from: string) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, from) as Promise, + reveal: (path: string) => ipcRenderer.invoke(REVEAL_CHANNEL, path) as Promise, }); diff --git a/apps/server/src/contract.ts b/apps/server/src/contract.ts index 3133d243..8cf728ed 100644 --- a/apps/server/src/contract.ts +++ b/apps/server/src/contract.ts @@ -27,8 +27,10 @@ export class CodeworkUnavailable extends Schema.TaggedError message: Schema.String, }) {} -/** The models could not be listed: a settings file or the model catalog is broken. */ +/** The models could not be listed: a settings file or the model catalog, at `path`, is broken. */ export class ModelsError extends Schema.TaggedError()("Server.ModelsError", { + source: Schema.Literals(["settings", "catalog"]), + path: Schema.String, message: Schema.String, }) {} diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 3df68ba3..38c1a6c3 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -57,12 +57,18 @@ export const layer = Contract.Api.toLayer( client("model.list", hostDir === undefined ? {} : { hostDir }).pipe( Effect.map((list) => ({ list })), Effect.catchTags({ - SettingsError: (error) => Effect.succeed({ failed: `${error.path}: ${error.detail}` }), - "Runner.ModelCatalogError": (error) => Effect.succeed({ failed: error.detail }), + SettingsError: (error) => + Effect.succeed({ + failed: new ModelsError({ source: "settings", path: error.path, message: error.detail }), + }), + "Runner.ModelCatalogError": (error) => + Effect.succeed({ + failed: new ModelsError({ source: "catalog", path: error.path, message: error.detail }), + }), }), ), ); - if ("failed" in listed) return yield* new ModelsError({ message: listed.failed }); + if ("failed" in listed) return yield* listed.failed; return listed.list; }), diff --git a/apps/web/src/components/banner.tsx b/apps/web/src/components/banner.tsx index f68a8b00..839f60dc 100644 --- a/apps/web/src/components/banner.tsx +++ b/apps/web/src/components/banner.tsx @@ -1,57 +1,93 @@ -import { CircleAlert, Clock, X } from "lucide-react"; +import { CircleAlert, Clock, type LucideIcon, TriangleAlert, X } from "lucide-react"; /** - * Something the reader should know before writing more: a short title and a one-line description. - * `expired` is a state the session is in, not a fault, so it reads neutral rather than red. + * 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. */ export interface Notice { readonly title: string; - readonly description: string; - readonly tone: "error" | "expired"; + readonly description?: string; + readonly tone: "error" | "warning" | "expired"; + readonly action?: NoticeAction; } +export interface NoticeAction { + readonly label: string; + readonly icon?: LucideIcon; + readonly disabled?: boolean; + readonly onClick: () => void; +} + +const tones: Record = { + // A faint tint and border of the tone's color over the composer's tray surface. + error: { + icon: CircleAlert, + text: "text-destructive", + surface: "border-destructive/30 bg-[color-mix(in_oklab,var(--color-destructive)_8%,var(--color-frame))]", + }, + warning: { + icon: TriangleAlert, + text: "text-warning", + surface: "border-warning/30 bg-[color-mix(in_oklab,var(--color-warning)_8%,var(--color-frame))]", + }, + expired: { + icon: Clock, + text: "text-warning", + surface: "border-warning/30 bg-[color-mix(in_oklab,var(--color-warning)_8%,var(--color-frame))]", + }, +}; + /** - * A notice tucked behind the composer's top edge, as its tray is tucked under the bottom one: subtle, - * attached to what it is about, and dismissible. The description is clamped to two lines, whole on hover. + * A notice tucked behind the composer's top edge, as its tray is tucked under the bottom one: subtle + * and attached to what it is about. Dismissible when `onDismiss` is given; a notice about a state + * clears itself instead. The description is clamped to two lines, whole on hover. */ export function ComposerNotice({ title, description, tone, + action, onDismiss, }: Notice & { - readonly onDismiss: () => void; + readonly onDismiss?: () => void; }) { - const Icon = tone === "expired" ? Clock : CircleAlert; - // A faint tint and border of the tone's color over the composer's tray surface. - const colors = - tone === "expired" - ? "border-warning/30 bg-[color-mix(in_oklab,var(--color-warning)_8%,var(--color-frame))]" - : "border-destructive/30 bg-[color-mix(in_oklab,var(--color-destructive)_8%,var(--color-frame))]"; + const { icon: Icon, text, surface } = tones[tone]; return (
- +

{title}

-

- {description} -

+ {description !== undefined && ( +

+ {description} +

+ )}
- + {action !== undefined && ( + + )} + {onDismiss !== undefined && ( + + )}
); } diff --git a/apps/web/src/components/model.tsx b/apps/web/src/components/model.tsx index 23f065a0..d2a49351 100644 --- a/apps/web/src/components/model.tsx +++ b/apps/web/src/components/model.tsx @@ -1,10 +1,10 @@ import { type ModelProvider, ThinkingLevel } from "@codeworksh/widget/codework"; -import { useAtomValue } from "@effect/atom-react"; +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import { Command as CommandPrimitive } from "cmdk"; -import { Option, Schema } from "effect"; +import { Cause, Option, Schema } from "effect"; import { AsyncResult, Atom } from "effect/reactivity"; -import { Brain, ChevronDown, Search } from "lucide-react"; -import { useMemo, useRef, useState } from "react"; +import { Brain, ChevronDown, FileSearch, RotateCw, Search } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { DropdownMenu, @@ -15,6 +15,9 @@ import { } from "@/components/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Server } from "../kernel/rpc"; +import { serversAtom } from "../settings/servers/local"; +import { fixIcons, problemOf, useFix } from "../settings/servers/problem"; +import type { Notice } from "./banner"; import { ProviderIcon } from "./provider"; export interface ModelRef { @@ -59,6 +62,71 @@ const modelsAtom = Atom.family((hostDir: string | undefined) => Server.query("models.list", hostDir === undefined ? {} : { hostDir }), ); +/** + * 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. + */ +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 }; + 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), + }, + }; +} + // The composer row's pickers: muted text that brightens on hover, like the prompt's own controls. const trigger = "flex h-7 min-w-0 shrink-0 items-center gap-1.5 rounded-[8px] px-2.5 outline-none press hover:bg-ink/5 hover:text-ink focus-visible:bg-ink/5 data-[state=open]:bg-ink/5 data-[state=open]:text-ink"; @@ -131,15 +199,11 @@ export function ModelPicker({ // The settings default can name a provider this server cannot call; it reads muted, not chosen. const unavailable = current !== undefined && list !== undefined && found?.provider.available !== true; - const label = - found?.model.name ?? current?.id ?? (AsyncResult.isFailure(result) ? "Models unavailable" : "Choose model"); - const empty = AsyncResult.isFailure(result) - ? "Could not list models." - : list === undefined - ? "Loading models…" - : providers.length === 0 - ? "No provider is signed in." - : "No models found"; + // Why is the composer's notice; the picker only reads as unusable until it clears. + const failed = AsyncResult.isFailure(result); + const label = failed ? "Unavailable" : (found?.model.name ?? current?.id ?? "Choose model"); + const empty = + list === undefined ? "Loading models…" : providers.length === 0 ? "No provider is signed in." : "No models found"; return ( - {current !== undefined && ( + {current !== undefined && !failed && ( Promise; + /** Shows `path` in the file manager, selected. */ + reveal: (path: string) => Promise; }; } } diff --git a/apps/web/src/landing/landing.tsx b/apps/web/src/landing/landing.tsx index bd1851ad..22705f88 100644 --- a/apps/web/src/landing/landing.tsx +++ b/apps/web/src/landing/landing.tsx @@ -10,12 +10,13 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { ComposerNotice } from "@/components/banner"; import { Composer, ComposerButton } from "@/components/composer"; import { makeHost } from "../kernel/host"; import { sessionAddress } from "../widgets/session/address"; import { Json, type Place as Where, topics } from "../widgets/session/schema"; import { useOpenElsewhere } from "../wm/open"; -import { ModelPicker, ThinkingPicker, useChoice } from "@/components/model"; +import { ModelPicker, ThinkingPicker, useChoice, useModelsNotice } from "@/components/model"; import { TitleBar } from "../shell/titlebar"; import { placeAtom, placeLabel, usePickFolder } from "./place"; import { projectsAtom } from "../widgets/session/projects"; @@ -43,6 +44,7 @@ export function Landing() { const { model, thinkingLevel: thinking } = choice; // A folder's project settings may add models or change the default. const hostDir = place.kind === "folder" ? place.path : undefined; + const notice = useModelsNotice(hostDir); const canSend = place.kind !== "none" && query.trim() !== "" && !pending; @@ -82,6 +84,7 @@ export function Landing() {
+ {notice !== undefined && } candidate.isDefault) : undefined; - const settings = () => void navigate({ to: "/settings/servers" }); const settingsLink = ( ); - // Asks the server, then loads the list again once it is up. - const run = (action: "start" | "restart") => { - if (server === undefined) return; - void act(server.id, action).then((done) => done && refreshProjects()); - }; - if (server === undefined) return ( @@ -42,82 +36,33 @@ export function ServerOffline() { ); - const { cli } = server; - if (cli === null) - return ( - - } onClick={settings}> - Set Up - - - ); - if ("failure" in cli) + const problem = problemOf(server); + if (problem === undefined) return ( - - } onClick={settings}> - Server Settings + + } + onClick={() => refreshProjects()} + > + Try Again ); - switch (server.server._tag) { - case "Stopped": - return ( - - } - disabled={running !== null} - onClick={() => run("start")} - > - {running === "start" ? "Starting…" : "Start Server"} - - {error !== null && } - {settingsLink} - - ); - case "Unreachable": - return ( - - } - disabled={running !== null} - onClick={() => run("restart")} - > - {running === "restart" ? "Restarting…" : "Restart"} - - {error !== null && } - {settingsLink} - - ); - case "Incompatible": - return ( - - } - onClick={settings} - > - Server Settings - - - ); - case "Running": - 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 }) { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index b20b3252..a9498e39 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,13 +1,18 @@ +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 { serversAtom } from "../settings/servers/local"; import { Palette } from "../shell/palette"; // 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 ( = { 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 9fb362a6..a0829dc2 100644 --- a/apps/web/src/settings/servers/servers.tsx +++ b/apps/web/src/settings/servers/servers.tsx @@ -12,6 +12,11 @@ export function ServersSettings() { const result = useAtomValue(serversAtom); const refresh = useAtomRefresh(serversAtom); 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(); + }, []); const [selectedId, setSelectedId] = useState(null); const [adding, setAdding] = useState(false); // The default until one is picked, and again when the picked one is gone. diff --git a/apps/web/src/widgets/session/view.tsx b/apps/web/src/widgets/session/view.tsx index 36330ca8..3a73cf4a 100644 --- a/apps/web/src/widgets/session/view.tsx +++ b/apps/web/src/widgets/session/view.tsx @@ -6,6 +6,7 @@ import { Brain, ChevronDown, ChevronRight, CircleAlert, MessageSquareText, Termi import { memo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ComposerNotice, type Notice } from "@/components/banner"; +import { useModelsNotice } from "@/components/model"; import { ago } from "../time"; import { sessionId } from "./address"; import { SessionComposer } from "./composer"; @@ -183,7 +184,9 @@ function Conversation({ id, instanceId }: { readonly id: string; readonly instan setTab({ title: project ?? "Session", subtitle: title }); }, [title, project, setTitle, setIcon, setTab]); - // A refused send, else why the last run failed; dismissing hides that one notice only. + // Models that can't be listed first: a state that clears itself, so it can't be dismissed. Then a + // refused send, else why the last run failed; dismissing hides that one notice only. + const models = useModelsNotice(state.status === "ready" ? state.info.hostDir : undefined); const [sendError, setSendError] = useState(); const [dismissed, setDismissed] = useState(); const notice: Notice | undefined = @@ -203,16 +206,18 @@ function Conversation({ id, instanceId }: { readonly id: string; readonly instan
- {shown && ( - { - setDismissed(notice); - setSendError(undefined); - }} - /> + {models !== undefined ? ( + + ) : ( + shown && ( + { + setDismissed(notice); + setSendError(undefined); + }} + /> + ) )} Date: Sun, 18 Oct 2026 11:57:44 +0530 Subject: [PATCH 2/6] feat(web): centered, faint empty state for a session with no messages Co-Authored-By: Claude Opus 5.5 --- apps/web/src/widgets/session/view.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/widgets/session/view.tsx b/apps/web/src/widgets/session/view.tsx index 3a73cf4a..88341fdd 100644 --- a/apps/web/src/widgets/session/view.tsx +++ b/apps/web/src/widgets/session/view.tsx @@ -263,7 +263,12 @@ function Transcript({ ), [running, turns.length], ); - if (turns.length === 0) return

No messages yet.

; + if (turns.length === 0) + return ( +
+

Send a message to start the conversation.

+
+ ); return ( data={turns} From 5c874c1c14ed4e939eb0c64b53804b629d063e47 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sun, 18 Oct 2026 12:00:44 +0530 Subject: [PATCH 3/6] feat(web): drop the Default badge from the reasoning menu Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/model.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/web/src/components/model.tsx b/apps/web/src/components/model.tsx index d2a49351..d72a0ab0 100644 --- a/apps/web/src/components/model.tsx +++ b/apps/web/src/components/model.tsx @@ -399,8 +399,6 @@ export function ThinkingPicker({ const levels = list === undefined || current === undefined ? [] : (lookup(list.providers, current)?.model.thinkingLevels ?? []); const picked = useRef(null); - // What settings would run this model at, marked so the reader knows what leaving it alone means. - const fallback = list === undefined ? undefined : clamp(list.default.thinkingLevel, levels); const shown = list === undefined ? undefined : clamp(value ?? list.default.thinkingLevel, levels); const choosable = shown !== undefined && levels.some((level) => level !== "off"); @@ -447,11 +445,6 @@ export function ThinkingPicker({ className="gap-1.5 rounded-md py-1 text-[13px] aria-current:bg-ink/[0.08]" > {levelLabels[level]} - {level === fallback && ( - - Default - - )} ))} From c0ca6dc1f85eaaa470230ff391307c0456334e74 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sun, 18 Oct 2026 12:48:17 +0530 Subject: [PATCH 4/6] feat(desktop): Make Default button, server status lines, command executables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings › Servers: "Make Default" sits in the server's header beside Remove; the list's default switch is gone. Each list row reads the server's state under its name (Running · host:port, Stopped, Not answering, Incompatible version, Not installed, Won't start). - A server's executable may be a command whose first word is a full path, such as node with --conditions=development and a source entry, so a server can run from source. A plain path, spaces included, stays the binary as before; ~ expands in every word. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli.ts | 52 +++++++++------- apps/server/src/servers.ts | 32 ++++++++-- apps/web/src/settings/servers/local.tsx | 72 +++++++++++++++-------- apps/web/src/settings/servers/servers.tsx | 23 +++----- 4 files changed, 117 insertions(+), 62 deletions(-) diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 891760c7..afedf42a 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -15,10 +15,6 @@ import { } from "./contract.ts"; import { Servers } from "./servers.ts"; -/** Splits typed arguments as a shell would for plain words and quotes: `--name "a b"` is two. */ -const splitArgs = (input: string) => - [...input.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((match) => match[1] ?? match[2] ?? match[3] ?? ""); - // Where the install script puts the binary unless told otherwise. const installDir = Config.String("CODEWORK_INSTALL_DIR").pipe( Config.withDefault(path.join(os.homedir(), ".codework", "bin")), @@ -64,14 +60,27 @@ const onPath = Effect.gen(function* () { .findLast((line) => line.startsWith("/")); }).pipe(Effect.orElseSucceed(() => undefined)); -const probe = (file: string, custom: boolean) => - run(file, ["--version"], "10 seconds").pipe( +interface Command { + readonly file: string; + /** Arguments that go before the CLI's own, as for a source entry run by `node`. */ + readonly prefix: ReadonlyArray; +} + +/** A chosen executable, split as typed; a found binary's path is never split, as it may hold spaces. */ +const split = (executable: string): Command => { + const [file = executable, ...prefix] = Servers.splitArgs(executable); + return { file, prefix }; +}; + +// `executable` is shown as `path`: a binary's path, or the whole command as stored. +const probe = ({ file, prefix }: Command, executable: string, custom: boolean) => + run(file, [...prefix, "--version"], "10 seconds").pipe( Effect.map(({ output, code }): CliInstall => { const version = /\bv?(\d+\.\d+\.\d+\S*)/.exec(output)?.[1]; - if (code === 0 && version !== undefined) return { path: file, custom, version }; - return { path: file, custom, failure: reason(output, code) }; + if (code === 0 && version !== undefined) return { path: executable, custom, version }; + return { path: executable, custom, failure: reason(output, code) }; }), - Effect.catch((error) => Effect.succeed({ path: file, custom, failure: error.message })), + Effect.catch((error) => Effect.succeed({ path: executable, custom, failure: error.message })), ); const exists = (file: string) => @@ -92,7 +101,7 @@ const found = Effect.gen(function* () { let broken: CliInstall | null = null; for (const file of candidates) { if (!(yield* exists(file))) continue; - const install = yield* probe(file, false); + const install = yield* probe({ file, prefix: [] }, file, false); if ("version" in install) return install; broken ??= install; } @@ -100,14 +109,16 @@ const found = Effect.gen(function* () { }); /** The `codework` a server runs: the one chosen for it, else the one found. */ -const executableOf = (row: Servers.Row, foundOnce: typeof found) => - row.executable === null - ? foundOnce - : Effect.flatMap(exists(row.executable), (present) => - present - ? probe(row.executable!, true) - : Effect.succeed({ path: row.executable!, custom: true, failure: "No file at this path." }), - ); +const executableOf = (row: Servers.Row, foundOnce: typeof found) => { + const { executable } = row; + if (executable === null) return foundOnce; + const command = split(executable); + return Effect.flatMap(exists(command.file), (present) => + present + ? probe(command, executable, true) + : Effect.succeed({ path: executable, custom: true, failure: "No file at this path." }), + ); +}; const alive = (pid: number) => Effect.sync(() => { @@ -220,8 +231,9 @@ export const handlers = { 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" ? [] : splitArgs(row.args); - const { output, code } = yield* run(cli.path, ["service", action, ...args], "30 seconds", { + 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( diff --git a/apps/server/src/servers.ts b/apps/server/src/servers.ts index c6dfb675..2d2fa194 100644 --- a/apps/server/src/servers.ts +++ b/apps/server/src/servers.ts @@ -1,4 +1,4 @@ -import { Clock, Config, Context, Effect, Layer, Schema, Stream, SubscriptionRef } from "effect"; +import { Clock, Config, Context, Effect, FileSystem, Layer, Schema, Stream, SubscriptionRef } from "effect"; import { SqlClient } from "effect/sql"; /* @effect-diagnostics nodeBuiltinImport:off -- paths typed in Settings expand against the OS home directory. */ import os from "node:os"; @@ -14,7 +14,12 @@ export const StoredEnv = Schema.Array( ); export type StoredEnv = typeof StoredEnv.Type; -/** A server as stored. A `null` executable or home is the one found. */ +/** Splits typed arguments as a shell would for plain words and quotes: `--name "a b"` is two. */ +export const splitArgs = (input: string) => + [...input.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((match) => match[1] ?? match[2] ?? match[3] ?? ""); + +/** A server as stored. A `null` executable or home is the one found. An executable is a path, or a + * command whose first word is one (`node --conditions=development …/index.ts`, running from source). */ export interface Row { readonly id: string; readonly kind: "local"; @@ -42,6 +47,24 @@ const chosenPath = (input: string | null) => return path.normalize(expanded); }); +const quoted = (word: string) => (/\s/.test(word) ? `"${word}"` : word); + +/** + * An executable typed in Settings: a path as {@link chosenPath}, optionally followed by arguments. + * A typed path naming a file is that file even with spaces in it, as an install folder may have. + */ +const chosenCommand = (fs: FileSystem.FileSystem, input: string | null) => + Effect.gen(function* () { + const whole = yield* chosenPath(input).pipe(Effect.orElseSucceed(() => null)); + if (whole !== null && (yield* fs.exists(whole).pipe(Effect.orElseSucceed(() => false)))) return quoted(whole); + const [program, ...rest] = splitArgs(input ?? ""); + const file = yield* chosenPath(program ?? null); + if (file === null) return null; + // `~/…` arguments expand too, so a source entry can be typed as it reads in a terminal. + const args = rest.map((word) => (word.startsWith("~/") ? path.join(os.homedir(), word.slice(1)) : word)); + return [file, ...args].map(quoted).join(" "); + }); + const VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; /** @@ -98,6 +121,7 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + const fs = yield* FileSystem.FileSystem; const fallback = yield* cliHome.pipe(Effect.orDie); const list = sql & { kind: string; env: string; isDefault: number }>` @@ -174,7 +198,7 @@ export const layer = Layer.effect( id, kind: "local", name: yield* name(input.name), - executable: yield* chosenPath(input.executable), + executable: yield* chosenCommand(fs, input.executable), home, args: input.args.trim(), env: "[]", @@ -193,7 +217,7 @@ export const layer = Layer.effect( const row = yield* get(id); const fields: Record = {}; if (patch.name !== undefined) fields.name = yield* name(patch.name); - if (patch.executable !== undefined) fields.executable = yield* chosenPath(patch.executable); + if (patch.executable !== undefined) fields.executable = yield* chosenCommand(fs, patch.executable); if (patch.args !== undefined) fields.args = patch.args.trim(); if (patch.env !== undefined) fields.env = JSON.stringify(yield* nextEnv(patch.env, row.env)); if (patch.home !== undefined) { diff --git a/apps/web/src/settings/servers/local.tsx b/apps/web/src/settings/servers/local.tsx index bd044b07..301f356e 100644 --- a/apps/web/src/settings/servers/local.tsx +++ b/apps/web/src/settings/servers/local.tsx @@ -15,7 +15,6 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; import { messageOf } from "@/lib/utils"; import brand from "../../assets/codework.png"; import { Server } from "../../kernel/rpc"; @@ -96,6 +95,7 @@ export function LocalItem({ status }: { status: ServerStatus }) { )} + Local {status.isDefault && Default} @@ -105,6 +105,40 @@ export function LocalItem({ status }: { status: ServerStatus }) { ); } +const DOTS: Record = { + success: "bg-success", + warning: "bg-warning", + danger: "bg-destructive", + info: "bg-info", + muted: "bg-ink-muted/50", +}; + +/** A server's state in a few words, as its row in the list reads it. */ +function stateOf({ cli, server }: ServerStatus): { tone: Tone; text: string } { + if (cli === null) return { tone: "warning", text: "Not installed" }; + if ("failure" in cli) return { tone: "danger", text: "Won't start" }; + switch (server._tag) { + case "Running": + return { tone: "success", text: `Running · ${new URL(server.url).host}` }; + case "Unreachable": + return { tone: "warning", text: "Not answering" }; + case "Incompatible": + return { tone: "danger", text: "Incompatible version" }; + case "Stopped": + return { tone: "muted", text: "Stopped" }; + } +} + +const StatusLine = ({ status }: { status: ServerStatus }) => { + const { tone, text } = stateOf(status); + return ( + + + {text} + + ); +}; + /** A local server: its name, its state and controls, then how it runs and what it starts with. */ export function LocalDetail({ status }: { status: ServerStatus }) { const update = useAtomSet(updateAtom, { mode: "promise" }); @@ -134,6 +168,7 @@ export function LocalDetail({ status }: { status: ServerStatus }) { Local {status.isDefault && Default} + @@ -247,35 +282,26 @@ export function LocalDetail({ status }: { status: ServerStatus }) { ); } -/** - * Whether the app connects through this server. There is always exactly one default, so the default's - * switch stays on: turning another one on moves the default there. - */ -export function DefaultSwitch({ status }: { status: ServerStatus }) { +/** Moves the default, the server the app connects through, here; the default itself shows none. */ +function MakeDefault({ status, onError }: { status: ServerStatus; onError: (message: string) => void }) { const setDefault = useAtomSet(setDefaultAtom, { mode: "promise" }); const [switching, setSwitching] = useState(false); - const [error, setError] = useState(null); + if (status.isDefault) return null; return ( - { - if (!on) return; + ); } diff --git a/apps/web/src/settings/servers/servers.tsx b/apps/web/src/settings/servers/servers.tsx index a0829dc2..0d8e349e 100644 --- a/apps/web/src/settings/servers/servers.tsx +++ b/apps/web/src/settings/servers/servers.tsx @@ -5,7 +5,7 @@ import { useEffect, useState } from "react"; import { Skeleton } from "@/components/ui/skeleton"; import { AddServer } from "./add"; -import { DefaultSwitch, LocalDetail, LocalItem, serversAtom } from "./local"; +import { LocalDetail, LocalItem, serversAtom } from "./local"; /** The CodeWork servers the app can work with, listed beside the one selected; the built-in one is always there. */ export function ServersSettings() { @@ -49,22 +49,15 @@ export function ServersSettings() { className="flex flex-col border-edge @max-3xl:border-b @3xl:overflow-y-auto @3xl:border-r" > {servers?.map((server) => ( -
setSelectedId(server.id)} > - - - - -
+ + ))}