From 8b12e0c153d8b41f05dae3a57188f2cb502f720c Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 17 Oct 2026 10:53:49 +0530 Subject: [PATCH 01/20] docs: note the model picker and the medium reasoning default Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39fc6237..644121f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ This file is the canonical source for unreleased changes and published release n - Added `project: { name }` to `session.create`. It names the session's project when the session creates it, ahead of the repository's and the folder's name; a project that already exists keeps its name. The name must be trimmed and not empty. - Added the `project.rename` RPC (`{ projectId, name }`), which renames a project and fails with `ProjectNotFoundError` for an unknown id. The harness exposes it as `Project.rename`. - The desktop start page creates a session in the folder you pick, or in a memory sandbox, sends your first prompt and opens the session. A memory session's project is named "In Memory". +- Added the `model.list` RPC (`{ hostDir? }`). It returns the providers the server holds credentials for (an API key in its environment, a stored OAuth login, or a keyless provider from settings), each model's supported thinking levels, and the `default` model and thinking level that settings pick for a session anchored at `hostDir`, with its display names. Without `hostDir` only the user's settings apply. Credentials are checked for presence, not validity. A broken settings file fails with `SettingsError`. +- The desktop start page's composer has a model picker and a Reasoning picker. The model picker lists providers down a rail with their models beside it and searches across all of them. With no model picked, settings decide; a default whose provider has no credentials shows muted and cannot be picked. Reasoning offers only the levels the model supports and marks the settings level as Default. The picks are remembered across restarts. + +### Changed + +- The default thinking level, for settings that name none, is now `medium` instead of `high`. ## [@codeworksh/cli@0.3.0] - 2026-10-13 From f49240ecf05e9f86f4ea87eaf5945dffae8d0808 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 17 Oct 2026 14:28:27 +0530 Subject: [PATCH 02/20] fix(plugin): give sandbox lifecycle errors readable messages SandboxNotFoundError, SandboxDriverNotRegisteredError, SandboxRemovedError, SandboxUnavailError and SandboxProviderError carried their reason only in fields, so a run that failed to mount its sandbox reached clients with an empty message. Each now says what happened. Co-Authored-By: Claude Opus 5.5 --- .../web/src/{landing => components}/model.tsx | 0 .../src/{landing => components}/monogram.tsx | 0 .../src/{landing => components}/provider.tsx | 0 packages/plugin/src/sandbox/errors.ts | 30 +++++++++++++++---- 4 files changed, 25 insertions(+), 5 deletions(-) rename apps/web/src/{landing => components}/model.tsx (100%) rename apps/web/src/{landing => components}/monogram.tsx (100%) rename apps/web/src/{landing => components}/provider.tsx (100%) diff --git a/apps/web/src/landing/model.tsx b/apps/web/src/components/model.tsx similarity index 100% rename from apps/web/src/landing/model.tsx rename to apps/web/src/components/model.tsx diff --git a/apps/web/src/landing/monogram.tsx b/apps/web/src/components/monogram.tsx similarity index 100% rename from apps/web/src/landing/monogram.tsx rename to apps/web/src/components/monogram.tsx diff --git a/apps/web/src/landing/provider.tsx b/apps/web/src/components/provider.tsx similarity index 100% rename from apps/web/src/landing/provider.tsx rename to apps/web/src/components/provider.tsx diff --git a/packages/plugin/src/sandbox/errors.ts b/packages/plugin/src/sandbox/errors.ts index 831aca66..61a7baee 100644 --- a/packages/plugin/src/sandbox/errors.ts +++ b/packages/plugin/src/sandbox/errors.ts @@ -7,7 +7,11 @@ import { SandboxInstance } from "./instance.ts"; */ export class SandboxNotFoundError extends Schema.TaggedError()("SandboxNotFoundError", { id: SandboxInstance.ID, -}) {} +}) { + override get message(): string { + return `Sandbox ${this.id} does not exist.`; + } +} export class SandboxDriverNotRegisteredError extends Schema.TaggedError()( "SandboxDriverNotRegisteredError", @@ -15,7 +19,11 @@ export class SandboxDriverNotRegisteredError extends Schema.TaggedError()( "SandboxDriverRegistrationError", @@ -59,12 +67,20 @@ export class SandboxMustBeStoppedError extends Schema.TaggedError()("SandboxRemovedError", { id: SandboxInstance.ID, removedAt: Schema.optional(Schema.DateTimeUtc), -}) {} +}) { + override get message(): string { + return `Sandbox ${this.id} was removed.`; + } +} export class SandboxUnavailError extends Schema.TaggedError()("SandboxUnavailError", { id: SandboxInstance.ID, reason: Schema.String, -}) {} +}) { + override get message(): string { + return `Sandbox ${this.id} is unavailable: ${this.reason}`; + } +} export class SandboxUnsupportedError extends Schema.TaggedError()("SandboxUnsupportedError", { id: Schema.optional(SandboxInstance.ID), @@ -92,7 +108,11 @@ export class SandboxProviderError extends Schema.TaggedError Date: Sat, 17 Oct 2026 14:28:27 +0530 Subject: [PATCH 03/20] feat: choose a session's model and reasoning in the Session widget The Session widget gets the start page's full composer with the model and Reasoning pickers. They show the session's own choice, which session.info now returns with its thinking level, and a pick is saved on the session through session.configure, applying from its next exchange. The pickers move to components/ so both composers share them, and model search matches every word of a query. A run that fails before reaching the model, such as a Memory session whose sandbox ended with the server, now shows as a short notice attached above the composer instead of doing nothing. The notice says it in plain words, without internal ids. The theme gains a warning color for it. Also: "In Memory" is now "Memory", the composer uses the raised frame surface so it floats in dark themes, the queue hint that pushed the composer up is gone, and the Session backend's live loops log whatever stops them and restart instead of dying silently. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/banner.tsx | 57 +++++++++++++++++++++ apps/web/src/components/composer.tsx | 4 +- apps/web/src/components/model.tsx | 14 +++--- apps/web/src/landing/landing.tsx | 6 +-- apps/web/src/landing/sidebar.tsx | 2 +- apps/web/src/style.css | 2 + apps/web/src/widgets/session/backend.ts | 50 +++++++++++++++++-- apps/web/src/widgets/session/composer.tsx | 51 +++++++++++++------ apps/web/src/widgets/session/schema.ts | 22 ++++++++ apps/web/src/widgets/session/view.tsx | 61 +++++++++++++++++++++-- apps/widget/src/codework.ts | 18 +++++-- packages/codework/src/server/contract.ts | 2 + packages/codework/src/server/handlers.ts | 1 + packages/harness/src/effect/session.ts | 7 ++- 14 files changed, 259 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/components/banner.tsx diff --git a/apps/web/src/components/banner.tsx b/apps/web/src/components/banner.tsx new file mode 100644 index 00000000..f68a8b00 --- /dev/null +++ b/apps/web/src/components/banner.tsx @@ -0,0 +1,57 @@ +import { CircleAlert, Clock, 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. + */ +export interface Notice { + readonly title: string; + readonly description: string; + readonly tone: "error" | "expired"; +} + +/** + * 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. + */ +export function ComposerNotice({ + title, + description, + tone, + onDismiss, +}: Notice & { + 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))]"; + return ( +
+ +
+

{title}

+

+ {description} +

+
+ +
+ ); +} diff --git a/apps/web/src/components/composer.tsx b/apps/web/src/components/composer.tsx index c024b4b6..efb36e4c 100644 --- a/apps/web/src/components/composer.tsx +++ b/apps/web/src/components/composer.tsx @@ -38,7 +38,9 @@ export function Composer({ return (
{ event.preventDefault(); onSubmit({ alternate: false }); diff --git a/apps/web/src/components/model.tsx b/apps/web/src/components/model.tsx index 290fafa9..47582f8d 100644 --- a/apps/web/src/components/model.tsx +++ b/apps/web/src/components/model.tsx @@ -1,9 +1,9 @@ -import { useAtomValue } from "@effect/atom-react"; import { type ModelProvider, ThinkingLevel } from "@codeworksh/widget/codework"; -import { Atom, AsyncResult } from "effect/reactivity"; +import { useAtomValue } from "@effect/atom-react"; import { Command as CommandPrimitive } from "cmdk"; -import { Brain, ChevronDown, Search } from "lucide-react"; import { Option, Schema } from "effect"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { Brain, ChevronDown, Search } from "lucide-react"; import { useMemo, useRef, useState } from "react"; import { @@ -121,9 +121,11 @@ export function ModelPicker({ providers .filter((provider) => needle !== "" || provider.id === shown) .flatMap((provider) => provider.models.map((model) => ({ provider, model }))) - .filter(({ provider, model }) => - `${model.name} ${model.id} ${provider.name}`.toLowerCase().includes(needle), - ), + // Every word must appear somewhere in the model's name, its id or its provider's name. + .filter(({ provider, model }) => { + const text = `${model.name} ${model.id} ${provider.name}`.toLowerCase(); + return needle.split(/\s+/).every((word) => text.includes(word)); + }), [providers, needle, shown], ); diff --git a/apps/web/src/landing/landing.tsx b/apps/web/src/landing/landing.tsx index b81ca355..51e3a2ed 100644 --- a/apps/web/src/landing/landing.tsx +++ b/apps/web/src/landing/landing.tsx @@ -17,7 +17,7 @@ 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 "./model"; +import { ModelPicker, ThinkingPicker, useChoice } from "@/components/model"; import { projectsAtom, SessionsSidebar, TitleBar } from "./sidebar"; import { Wordmark } from "./wordmark"; @@ -34,7 +34,7 @@ const placeLabel = (place: Place) => place.kind === "none" ? "Select Project" : place.kind === "memory" - ? "In Memory" + ? "Memory" : (place.path.split("/").findLast(Boolean) ?? place.path); /** @@ -151,7 +151,7 @@ export function Landing() { Project setPlace({ kind: "memory" })}> - In Memory + Memory {place.kind === "memory" && ( )} diff --git a/apps/web/src/landing/sidebar.tsx b/apps/web/src/landing/sidebar.tsx index 4b0de97b..ce3a5d53 100644 --- a/apps/web/src/landing/sidebar.tsx +++ b/apps/web/src/landing/sidebar.tsx @@ -21,7 +21,7 @@ import { insetTitleBar, NewWorkspaceButton, SettingsButton } from "../shell/titl import { useOpenElsewhere } from "../wm/open"; import { useWorkspaces } from "../wm/workspaces"; import { ago } from "../widgets/time"; -import { Monogram } from "./monogram"; +import { Monogram } from "@/components/monogram"; /** The sidebar's sessions; refresh it once a new session exists. */ export const projectsAtom = Server.query("projects.list", undefined); diff --git a/apps/web/src/style.css b/apps/web/src/style.css index f0f01c48..dabd4c78 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -22,6 +22,7 @@ --cw-brand-ink: var(--t-brand-ink); --cw-scrollbar: color-mix(in oklab, var(--t-text) 16%, transparent); --cw-danger: light-dark(#dc2626, #f87171); + --cw-warning: light-dark(#d97706, #fbbf24); --cw-highlight: light-dark(rgb(255 255 255 / 0.9), color-mix(in oklab, var(--t-text) 9%, transparent)); --cw-shadow-near: light-dark(rgb(0 0 0 / 0.06), rgb(0 0 0 / 0.35)); --cw-shadow-far: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.5)); @@ -63,6 +64,7 @@ --color-accent: color-mix(in oklab, var(--cw-ink) 8%, transparent); --color-accent-foreground: var(--cw-ink); --color-destructive: var(--cw-danger); + --color-warning: var(--cw-warning); --color-border: var(--cw-edge); --color-input: var(--cw-edge-focus); --color-ring: color-mix(in oklab, var(--cw-brand) 50%, transparent); diff --git a/apps/web/src/widgets/session/backend.ts b/apps/web/src/widgets/session/backend.ts index ee5b80fe..4c7539b6 100644 --- a/apps/web/src/widgets/session/backend.ts +++ b/apps/web/src/widgets/session/backend.ts @@ -3,6 +3,7 @@ import { type EventEnvelope, HistoryEntry, type Part, type SessionInfo } from "@ import { Deferred, Effect, Fiber, Option, Schedule, Schema, Stream } from "effect"; import { + Configure, Create, type Created, Follow, @@ -22,6 +23,9 @@ import { const toInfo = (info: SessionInfo): Info => ({ title: info.title, updated: info.updatedAt, + ...(info.hostDir === undefined ? {} : { hostDir: info.hostDir }), + ...(info.sandbox === undefined ? {} : { sandbox: info.sandbox.driver }), + ...(info.thinkingLevel === undefined ? {} : { thinkingLevel: info.thinkingLevel }), ...(info.project === undefined ? {} : { project: info.project }), ...(info.model === undefined ? {} : { model: info.model }), }); @@ -59,6 +63,9 @@ const Delta = Schema.Struct({ }); const decodeDelta = Schema.decodeUnknownOption(Delta); const decodeOfSession = Schema.decodeUnknownOption(Schema.Struct({ sessionId: Schema.String })); +const decodeFailed = Schema.decodeUnknownOption( + Schema.Struct({ sessionId: Schema.String, error: Schema.Struct({ type: Schema.String, message: Schema.String }) }), +); /** Appends a delta to the part it belongs to; a part not seen yet starts with it. */ const fold = (current: Part | undefined, kind: "text" | "thinking", delta: string): Part => @@ -218,9 +225,23 @@ export const session = defineBackend({ dirty.add(delta.messageId); }); } + // A run can fail before it reaches the model, a sandbox that is gone say, and then nothing + // in the session's log records it: this is the only place the reason shows. + case "session.execution.failed": { + const failed = Option.getOrUndefined(decodeFailed(envelope.data)); + if (failed === undefined || !following.has(failed.sessionId)) return Effect.void; + return publishLive(failed.sessionId, { _tag: "Running", running: false }).pipe( + Effect.andThen( + publishLive(failed.sessionId, { + _tag: "Failed", + kind: failed.error.type, + message: failed.error.message, + }), + ), + ); + } case "session.execution.started": case "session.execution.succeeded": - case "session.execution.failed": case "session.execution.interrupted": { const sessionId = Option.getOrUndefined(decodeOfSession(envelope.data))?.sessionId; if (sessionId === undefined || !following.has(sessionId)) return Effect.void; @@ -278,11 +299,17 @@ export const session = defineBackend({ Effect.repeat({ while: () => following.has(id), schedule: Schedule.spaced("1 second") }), ); - yield* flush.pipe(Effect.repeat(Schedule.spaced("50 millis")), Effect.forkScoped); + // Both loops outlive anything that stops them, a defect included, and say what it was: one that + // stopped quietly would freeze every view's live updates while sends still went through. + yield* flush.pipe( + Effect.catchCause((cause) => Effect.logWarning("Session drafts stopped; restarting", cause)), + Effect.repeat(Schedule.spaced("50 millis")), + Effect.forkScoped, + ); // Nothing here is replayed, so a reconnect only resumes from what happens next. yield* codework("event.subscribe", {}).pipe( Stream.runForEach(observe), - Effect.retry(Schedule.spaced("1 second")), + Effect.catchCause((cause) => Effect.logWarning("Session live events stopped; resubscribing", cause)), Effect.repeat(Schedule.spaced("1 second")), Effect.forkScoped, ); @@ -379,7 +406,7 @@ export const session = defineBackend({ codework("session.create", { ...(place._tag === "Folder" ? { directory: place.path, hostDir: place.path } - : { sandbox: { driver: "memory" }, project: { name: "In Memory" } }), + : { sandbox: { driver: "memory" }, project: { name: "Memory" } }), runtime: { ...(model === undefined ? {} : { model }), ...(thinkingLevel === undefined ? {} : { thinkingLevel }), @@ -392,6 +419,21 @@ export const session = defineBackend({ ), ); + // The session's own log moves with the change, so the refresh that follows publishes the new info. + yield* handle(topics.configure, Configure, ({ sessionId, model, thinkingLevel }) => + codework("session.configure", { + sessionId, + runtime: { + ...(model === undefined ? {} : { model }), + ...(thinkingLevel === undefined ? {} : { thinkingLevel }), + }, + }).pipe( + Effect.as({ _tag: "Done" }), + Effect.catch((error) => Effect.succeed({ _tag: "Failed", message: describe(error) })), + Effect.flatMap((reply) => encodeReply(reply).pipe(Effect.orDie)), + ), + ); + yield* handle(topics.interrupt, Interrupt, ({ sessionId }) => codework("session.interrupt", { sessionId }).pipe( Effect.as({ _tag: "Done" }), diff --git a/apps/web/src/widgets/session/composer.tsx b/apps/web/src/widgets/session/composer.tsx index 6d983a8f..91d4243e 100644 --- a/apps/web/src/widgets/session/composer.tsx +++ b/apps/web/src/widgets/session/composer.tsx @@ -1,14 +1,16 @@ -import { isMac, useBus, useKv } from "@codeworksh/widget/view"; +import { useBus, useKv } from "@codeworksh/widget/view"; import { Option, Schema } from "effect"; import { ArrowUp, Square } from "lucide-react"; import { useEffect, useState } from "react"; import { Composer, ComposerButton, type Submit } from "@/components/composer"; -import { type Delivery } from "@codeworksh/widget/codework"; -import { Json, topics } from "./schema"; +import { ModelPicker, type ModelRef, ThinkingPicker } from "@/components/model"; +import type { Delivery, ThinkingLevel } from "@codeworksh/widget/codework"; +import { type Info, Json, topics } from "./schema"; const decodeReply = Schema.decodeUnknownOption(Json.Reply); const encodePrompt = Schema.encodeSync(Json.Prompt); +const encodeConfigure = Schema.encodeSync(Json.Configure); const draftKey = (instanceId: string) => `widget/${instanceId}/draft`; @@ -22,22 +24,33 @@ const unreachable = "The CodeWork server is not reachable."; * The Session widget's prompt box. A send is a request on the session backend's prompt topic, with * an id the view chooses for the user message it becomes; the message itself arrives as an update. * The text stays until the backend accepts it, and the unsent draft survives a remount. + * + * The model and reasoning pickers show the session's own choice, which CodeWork keeps with the + * session; a pick shows at once and is sent as a configure request, applying from the next exchange. */ export function SessionComposer({ sessionId, instanceId, running, + info, + onError, }: { readonly sessionId: string; readonly instanceId: string; readonly running: boolean; + readonly info: Info; + /** A send or configure the backend refused, or undefined as a new one starts; the view shows it. */ + readonly onError: (message: string | undefined) => void; }) { const bus = useBus(); const kv = useKv(); const [text, setText] = useState(""); const [loaded, setLoaded] = useState(false); const [pending, setPending] = useState(false); - const [error, setError] = useState(); + // Picks not yet reflected in the session's info; the info wins once it carries them. + const [picked, setPicked] = useState<{ model?: ModelRef; thinkingLevel?: ThinkingLevel }>({}); + const model = picked.model ?? info.model; + const thinkingLevel = picked.thinkingLevel ?? info.thinkingLevel; useEffect(() => { let active = true; @@ -62,16 +75,16 @@ export function SessionComposer({ }, [kv, instanceId, loaded, text]); const ask = async (topic: string, payload: Schema.Json) => { - setError(undefined); + onError(undefined); try { const reply = decodeReply((await bus.request({ topic, payload })).payload); if (Option.isSome(reply) && reply.value._tag === "Failed") { - setError(reply.value.message); + onError(reply.value.message); return false; } return true; } catch { - setError(unreachable); + onError(unreachable); return false; } }; @@ -94,14 +107,29 @@ export function SessionComposer({ if (sent) setText(""); }; + const configure = (change: { model?: ModelRef; thinkingLevel?: ThinkingLevel }) => { + setPicked((current) => ({ ...current, ...change })); + void ask(topics.configure, encodeConfigure({ sessionId, ...change })); + }; + const empty = text.trim() === ""; return ( void send(submit)} placeholder={running ? "Steer the agent, or queue a follow-up" : "Ask for changes or send a follow-up"} + controls={ + <> + configure({ model: next })} /> + configure({ thinkingLevel: level })} + /> + + } actions={ <> {running && ( @@ -114,13 +142,6 @@ export function SessionComposer({ } - tray={ - error !== undefined ? ( - {error} - ) : running ? ( - {isMac ? "⌘" : "Ctrl"}+Enter queues it for after this turn - ) : undefined - } /> ); } diff --git a/apps/web/src/widgets/session/schema.ts b/apps/web/src/widgets/session/schema.ts index 33ba1b20..bca8365b 100644 --- a/apps/web/src/widgets/session/schema.ts +++ b/apps/web/src/widgets/session/schema.ts @@ -12,6 +12,8 @@ export const topics = { /** Requests: create a session and send its first prompt; answered with its id. */ create: "codework/session.create", prompt: "codework/session.prompt", + /** Requests: choose a session's model or thinking level; they apply from its next exchange. */ + configure: "codework/session.configure", interrupt: "codework/session.interrupt", /** Requests: a window of a followed session's conversation, newest first back from `before`. */ page: "codework/session.page", @@ -61,6 +63,14 @@ export type Created = typeof Created.Type; export const Interrupt = Schema.Struct({ sessionId: Schema.String }); +/** A model or thinking level chosen for a session; an absent key is left as it is. */ +export const Configure = Schema.Struct({ + sessionId: Schema.String, + model: Schema.optionalKey(Schema.Struct({ provider: Schema.String, id: Schema.String })), + thinkingLevel: Schema.optionalKey(ThinkingLevel), +}); +export type Configure = typeof Configure.Type; + export const Page = Schema.Struct({ sessionId: Schema.String, /** The oldest message the view holds; omitted asks for the newest messages. */ @@ -77,8 +87,14 @@ export type Reply = typeof Reply.Type; export const Info = Schema.Struct({ title: Schema.String, + /** The host folder the session is anchored to, whose project settings it reads. */ + hostDir: Schema.optionalKey(Schema.String), + /** The sandbox driver the session runs in, `memory` say; absent for the host itself. */ + sandbox: Schema.optionalKey(Schema.String), project: Schema.optionalKey(Schema.Struct({ id: Schema.String, name: Schema.String })), model: Schema.optionalKey(Schema.Struct({ provider: Schema.String, id: Schema.String })), + /** The thinking level chosen for the session; absent when settings decide. */ + thinkingLevel: Schema.optionalKey(ThinkingLevel), /** Last activity, epoch milliseconds. */ updated: Schema.Number, }); @@ -118,6 +134,11 @@ export const Update = Schema.Union([ Schema.TaggedStruct("Removed", { id: Schema.String }), /** Whether the agent is working on the session; sent live only, never stored. */ Schema.TaggedStruct("Running", { running: Schema.Boolean }), + /** + * Why the agent's work on the session failed: CodeWork's failure category (`sandbox-mount-error` + * and the like) and its message. Sent live only, never stored. + */ + Schema.TaggedStruct("Failed", { kind: Schema.String, message: Schema.String }), ]); export type Update = typeof Update.Type; @@ -128,6 +149,7 @@ export const Json = { Create: Schema.toCodecJson(Create), Created: Schema.toCodecJson(Created), Interrupt: Schema.toCodecJson(Interrupt), + Configure: Schema.toCodecJson(Configure), Window: Schema.toCodecJson(Window), Reply: Schema.toCodecJson(Reply), Snapshot: Schema.toCodecJson(Snapshot), diff --git a/apps/web/src/widgets/session/view.tsx b/apps/web/src/widgets/session/view.tsx index 3d87994b..24d3a1ce 100644 --- a/apps/web/src/widgets/session/view.tsx +++ b/apps/web/src/widgets/session/view.tsx @@ -5,6 +5,7 @@ import { Option, Schema } from "effect"; import { Brain, ChevronDown, ChevronRight, CircleAlert, MessageSquareText, Terminal, Wrench } from "lucide-react"; import { memo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ComposerNotice, type Notice } from "@/components/banner"; import { ago } from "../time"; import { sessionId } from "./address"; import { SessionComposer } from "./composer"; @@ -38,10 +39,31 @@ type State = readonly after: number; /** Whether the agent works, once the backend has said; until then a draft reply implies it. */ readonly running: boolean | undefined; + /** Why the last run failed, until another one starts. */ + readonly failure?: Notice; }; type Ready = Extract; +/** What a failed run reads as: in the reader's words, never CodeWork's internal ids. */ +const noticeOf = (kind: string, message: string, sandbox: string | undefined): Notice => { + switch (kind) { + // The session's sandbox is gone, so it can't run; its conversation is still saved and readable. + // A memory one lives only as long as the server that made it. + case "sandbox-mount-error": + return { + title: "Expired", + description: + sandbox === "memory" + ? "Memory sessions can’t continue after closed. Start a new session to keep going." + : "This session can't be picked up again. Start a new session to keep going.", + tone: "expired", + }; + default: + return { title: "Something went wrong", description: message, tone: "error" }; + } +}; + const apply = (state: State, update: Update): State => { if (state.status !== "ready") return state; switch (update._tag) { @@ -49,8 +71,12 @@ const apply = (state: State, update: Update): State => { return { ...state, info: update.info }; case "Removed": return { ...state, messages: state.messages.filter((message) => message.id !== update.id) }; - case "Running": - return { ...state, running: update.running }; + case "Running": { + const { failure: _, ...rest } = state; + return update.running ? { ...rest, running: true } : { ...state, running: false }; + } + case "Failed": + return { ...state, failure: noticeOf(update.kind, update.message, state.info.sandbox) }; case "Message": { const index = state.messages.findIndex((message) => message.id === update.message.id); const oldest = state.messages[0]; @@ -153,6 +179,18 @@ 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. + const [sendError, setSendError] = useState(); + const [dismissed, setDismissed] = useState(); + const notice: Notice | undefined = + sendError !== undefined + ? { title: "Not sent", description: sendError, tone: "error" } + : state.status === "ready" + ? state.failure + : undefined; + const shown = + notice !== undefined && (dismissed?.title !== notice.title || dismissed.description !== notice.description); + if (state.status === "loading") return null; if (state.status === "failed") return

{state.message}

; const last = state.messages.at(-1); @@ -161,7 +199,24 @@ function Conversation({ id, instanceId }: { readonly id: string; readonly instan
- + {shown && ( + { + setDismissed(notice); + setSendError(undefined); + }} + /> + )} +
); diff --git a/apps/widget/src/codework.ts b/apps/widget/src/codework.ts index 05236209..3fa6e6c9 100644 --- a/apps/widget/src/codework.ts +++ b/apps/widget/src/codework.ts @@ -59,14 +59,22 @@ export const HistoryEntry = Schema.Struct({ }); export type HistoryEntry = typeof HistoryEntry.Type; +/** How hard a model reasons, ascending. */ +export const ThinkingLevel = Schema.Literals(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); +export type ThinkingLevel = typeof ThinkingLevel.Type; + export const SessionInfo = Schema.Struct({ id: Schema.String, title: Schema.String, directory: Schema.String, hostDir: Schema.optionalKey(Schema.String), + /** The sandbox the session runs in; absent for the host itself. */ + sandbox: Schema.optionalKey(Schema.Struct({ driver: Schema.String })), project: Schema.optionalKey(Schema.Struct({ id: Schema.String, name: Schema.String })), updatedAt: Schema.Finite, model: Schema.optionalKey(Schema.Struct({ provider: Schema.String, id: Schema.String })), + /** The thinking level chosen for this session; absent when settings decide. */ + thinkingLevel: Schema.optionalKey(ThinkingLevel), }); export type SessionInfo = typeof SessionInfo.Type; @@ -115,10 +123,6 @@ export class EventEncodingError extends Schema.TaggedError() cause: Schema.Defect(), }) {} -/** How hard a model reasons, ascending. */ -export const ThinkingLevel = Schema.Literals(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); -export type ThinkingLevel = typeof ThinkingLevel.Type; - /** A provider the server can call, with the models a session may use from it. */ export const ModelProvider = Schema.Struct({ id: Schema.String, @@ -227,6 +231,12 @@ export const Api = RpcGroup.make( payload: {}, success: Schema.Array(SessionInfo), }), + /** Sets the session's model and thinking level over its settings; they apply from its next exchange. */ + Rpc.make("session.configure", { + payload: { sessionId: Schema.String, runtime: RuntimeConfig }, + success: SessionInfo, + error: SessionNotFoundError, + }), Rpc.make("session.info", { payload: { sessionId: Schema.String }, success: SessionInfo, diff --git a/packages/codework/src/server/contract.ts b/packages/codework/src/server/contract.ts index fb65040a..107358e0 100644 --- a/packages/codework/src/server/contract.ts +++ b/packages/codework/src/server/contract.ts @@ -48,6 +48,8 @@ export const SessionInfo = Schema.Struct({ updatedAt: Schema.Finite, /** The model chosen for this session; absent when settings decide. */ model: optional(Schema.Struct({ provider: Schema.String, id: Schema.String })), + /** The thinking level chosen for this session; absent when settings decide. */ + thinkingLevel: optional(Schema.Enum(Model.ThinkingLevelEnum)), }); export type SessionInfo = typeof SessionInfo.Type; diff --git a/packages/codework/src/server/handlers.ts b/packages/codework/src/server/handlers.ts index 51d302a6..b8b8fd65 100644 --- a/packages/codework/src/server/handlers.ts +++ b/packages/codework/src/server/handlers.ts @@ -50,6 +50,7 @@ const toSessionInfo = (info: Session.Info): SessionInfo => ({ ...(info.project === undefined ? {} : { project: info.project }), updatedAt: info.updatedAt, ...(info.model === undefined ? {} : { model: info.model }), + ...(info.thinkingLevel === undefined ? {} : { thinkingLevel: info.thinkingLevel }), }); // Wire configs name only the keys they set, so `undefined` never reaches the diff --git a/packages/harness/src/effect/session.ts b/packages/harness/src/effect/session.ts index ff533a91..15c2f8c9 100644 --- a/packages/harness/src/effect/session.ts +++ b/packages/harness/src/effect/session.ts @@ -102,6 +102,8 @@ export interface Info { readonly updatedAt: number; /** The model chosen for this session over settings; absent when settings decide. */ readonly model?: { readonly provider: string; readonly id: string }; + /** The thinking level chosen for this session over settings; absent when settings decide. */ + readonly thinkingLevel?: Model.ThinkingLevel; } export interface Handle { @@ -177,7 +179,9 @@ const makeHandle = Effect.fn("Session.makeHandle")(function* (id: SessionSchema. : Option.getOrUndefined(yield* sandboxes.get(space.env)); const hostDir = Option.getOrUndefined(row.hostDir); const project = Option.getOrUndefined(yield* sessions.project(id)); - const model = Option.getOrUndefined(row.config)?.model; + const config = Option.getOrUndefined(row.config); + const model = config?.model; + const thinkingLevel = config?.thinkingLevel; return { id, title: row.title, @@ -188,6 +192,7 @@ const makeHandle = Effect.fn("Session.makeHandle")(function* (id: SessionSchema. ...(project === undefined ? {} : { project: { id: project.id, name: project.name } }), updatedAt: DateTime.toEpochMillis(row.updatedAt), ...(model === undefined ? {} : { model }), + ...(thinkingLevel === undefined ? {} : { thinkingLevel }), }; }).pipe(Effect.withSpan("Session.info")); From cbc5c8dc5ada4164bd6391751d8e66a36f61594b Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 17 Oct 2026 15:11:21 +0530 Subject: [PATCH 04/20] feat(web): invite adding a project on an empty start page With no projects, the sidebar shows "No Projects Yet" and an "Add Project" button that opens the folder picker and sets the composer's project; with a project picked but no sessions it shows "No Sessions Yet". The composer's project menu no longer offers Memory: Memory sessions end with the server and keep their files in its RAM, so they stay hidden until that changes. The Session backend can still create them. The sidebar's search icon now centers on the input instead of sitting low. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/landing/landing.tsx | 35 ++++++----------- apps/web/src/landing/sidebar.tsx | 65 ++++++++++++++++++++++++-------- 2 files changed, 61 insertions(+), 39 deletions(-) diff --git a/apps/web/src/landing/landing.tsx b/apps/web/src/landing/landing.tsx index 51e3a2ed..af52a8c1 100644 --- a/apps/web/src/landing/landing.tsx +++ b/apps/web/src/landing/landing.tsx @@ -1,6 +1,6 @@ import { RegistryContext, useAtomRefresh } from "@effect/atom-react"; import { Option, Schema } from "effect"; -import { ArrowUp, Check, ChevronDown, Folder, FolderPlus, MemoryStick } from "lucide-react"; +import { ArrowUp, ChevronDown, Folder, FolderPlus } from "lucide-react"; import { type CSSProperties, useContext, useMemo, useState } from "react"; import { @@ -8,7 +8,6 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Composer, ComposerButton } from "@/components/composer"; @@ -26,16 +25,15 @@ const bridge = window.desktopBridge; const encodeCreate = Schema.encodeSync(Json.Create); const decodeCreated = Schema.decodeUnknownOption(Json.Created); -/** Where a new project lives: nowhere yet, only in memory, or a folder on disk. */ -type Place = { kind: "none" } | { kind: "memory" } | { kind: "folder"; path: string }; +/** + * Where a new project lives: nowhere yet, or a folder on disk. Memory sessions are not offered + * until they outlive the server that made them; the Session backend can still create them. + */ +type Place = { kind: "none" } | { kind: "folder"; path: string }; // A folder shows by its name alone; the trigger's tooltip carries the full path. const placeLabel = (place: Place) => - place.kind === "none" - ? "Select Project" - : place.kind === "memory" - ? "Memory" - : (place.path.split("/").findLast(Boolean) ?? place.path); + place.kind === "none" ? "Select Project" : (place.path.split("/").findLast(Boolean) ?? place.path); /** * The start page: pick where to work and send the first prompt. The Session backend creates the @@ -54,14 +52,14 @@ export function Landing() { // Remembered across restarts; an absent pick leaves it to settings. const [choice, setChoice] = useChoice(); const { model, thinkingLevel: thinking } = choice; - // A folder's project settings may add models or change the default; memory has only the user's. + // A folder's project settings may add models or change the default. const hostDir = place.kind === "folder" ? place.path : undefined; const canSend = place.kind !== "none" && query.trim() !== "" && !pending; const send = async () => { - if (!canSend) return; - const where: Where = place.kind === "folder" ? { _tag: "Folder", path: place.path } : { _tag: "Memory" }; + if (!canSend || place.kind !== "folder") return; + const where: Where = { _tag: "Folder", path: place.path }; setPending(true); setError(undefined); const created = await bus @@ -95,7 +93,6 @@ export function Landing() { const picked = await bridge.pickFolder(place.kind === "folder" ? place.path : bridge.home); if (picked !== null) setPlace({ kind: "folder", path: picked }); }; - const PlaceIcon = place.kind === "memory" ? MemoryStick : Folder; return ( - +
@@ -143,20 +140,12 @@ export function Landing() { title={place.kind === "folder" ? place.path : undefined} className="flex h-6 min-w-0 items-center gap-1 rounded-[8px] px-1.75 leading-4 outline-none transition-colors hover:bg-ink/5 hover:text-ink focus-visible:bg-ink/5 data-[state=open]:bg-ink/5 data-[state=open]:text-ink" > - + {placeLabel(place)} Project - setPlace({ kind: "memory" })}> - - Memory - {place.kind === "memory" && ( - - )} - - void pickFolder?.()} diff --git a/apps/web/src/landing/sidebar.tsx b/apps/web/src/landing/sidebar.tsx index ce3a5d53..826da366 100644 --- a/apps/web/src/landing/sidebar.tsx +++ b/apps/web/src/landing/sidebar.tsx @@ -1,7 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { Option } from "effect"; import { AsyncResult } from "effect/reactivity"; -import { Search } from "lucide-react"; +import { Plus, Search } from "lucide-react"; import { type MouseEvent, useState } from "react"; import { @@ -26,8 +26,18 @@ import { Monogram } from "@/components/monogram"; /** The sidebar's sessions; refresh it once a new session exists. */ export const projectsAtom = Server.query("projects.list", undefined); -/** Recent sessions across every project, newest first. */ -export function SessionsSidebar() { +/** + * Recent sessions across every project, newest first. With none yet it invites adding a project, + * the folder the start page's composer then works in; `project` is that folder once picked. + */ +export function SessionsSidebar({ + project, + onAddProject, +}: { + readonly project: string | undefined; + /** Absent outside the desktop app, which alone can open the folder dialog. */ + readonly onAddProject: (() => void) | undefined; +}) { const result = useAtomValue(projectsAtom); const [query, setQuery] = useState(""); const openElsewhere = useOpenElsewhere(); @@ -49,16 +59,19 @@ export function SessionsSidebar() { {/* The window's drag region, clear of the macOS traffic lights. */}
-
- - setQuery(event.target.value)} - className="h-8 rounded-[8px] border-transparent bg-transparent pl-8 text-[13px] shadow-none focus-visible:bg-ink/5" - /> +
+ {/* The icon centers on the input alone, not on the padding below it. */} +
+ + setQuery(event.target.value)} + className="h-8 rounded-[8px] border-transparent bg-transparent pl-8 text-[13px] shadow-none focus-visible:bg-ink/5" + /> +
@@ -72,9 +85,29 @@ export function SessionsSidebar() { })}

) : AsyncResult.isSuccess(result) && sessions.length === 0 ? ( -

- {needle === "" ? "No sessions yet." : `No sessions match “${query}”.`} -

+ needle !== "" ? ( +

No sessions match “{query}”.

+ ) : ( +
+ {project === undefined && result.value.length === 0 ? ( + <> + No Projects Yet + {onAddProject !== undefined && ( + + )} + + ) : ( + No Sessions Yet + )} +
+ ) ) : (
    {sessions.map((session) => ( From c256961258f7a62de4abd8a506f901f81de3dec7 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 17 Oct 2026 15:38:09 +0530 Subject: [PATCH 05/20] feat: move widgets between workspaces and lead session titles with their project A frame's Move menu lists the other workspaces and a New Workspace entry, which opens an auto workspace for the frame. Frame titles take an optional muted lead, which the session widget fills with its project. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/widgets/session/view.tsx | 3 +- apps/web/src/wm/chrome.ts | 5 +- apps/web/src/wm/desktop.tsx | 62 +++++++++++++++----- apps/web/src/wm/frame.tsx | 81 ++++++++++++++++++++++++++- apps/web/src/wm/overflow.tsx | 8 ++- apps/widget/src/view/shell.tsx | 7 ++- 6 files changed, 144 insertions(+), 22 deletions(-) diff --git a/apps/web/src/widgets/session/view.tsx b/apps/web/src/widgets/session/view.tsx index 24d3a1ce..65a28128 100644 --- a/apps/web/src/widgets/session/view.tsx +++ b/apps/web/src/widgets/session/view.tsx @@ -173,7 +173,8 @@ function Conversation({ id, instanceId }: { readonly id: string; readonly instan useEffect(() => { if (title === undefined) return; - setTitle(title); + // The project leads, so sessions from different projects tell apart at a glance. + setTitle(title, project); setIcon(MessageSquareText); // While focused, the workspace's tab reads as this session: its project over its title. setTab({ title: project ?? "Session", subtitle: title }); diff --git a/apps/web/src/wm/chrome.ts b/apps/web/src/wm/chrome.ts index 24e12457..0e1053be 100644 --- a/apps/web/src/wm/chrome.ts +++ b/apps/web/src/wm/chrome.ts @@ -8,6 +8,8 @@ import type { Instance } from "./instances"; /** What a widget set on its frame at runtime; it outranks the instance and definition. */ export interface Chrome { readonly title?: string; + /** Shown before the title, quieter. */ + readonly lead?: string | undefined; readonly icon?: LucideIcon; /** What the workspace tab shows while this frame is its focused one. */ readonly tab?: Tab; @@ -32,11 +34,12 @@ export const focusedAtom = Atom.make>(new Map()).pip */ export const tabOwnerAtom = Atom.make>(new Map()).pipe(Atom.keepAlive); -/** A frame's title and icon: what the widget set, else the instance's, else its definition's. */ +/** A frame's title, lead and icon: what the widget set, else the instance's, else its definition's. */ export function chromeOf(instance: Instance, chrome: Chrome | undefined) { const definition = registry.get(instance.kind); return { title: chrome?.title ?? instance.title ?? definition?.title ?? instance.id, + lead: chrome?.title === undefined ? undefined : chrome.lead, icon: chrome?.icon ?? definition?.icon, }; } diff --git a/apps/web/src/wm/desktop.tsx b/apps/web/src/wm/desktop.tsx index a1dbc66a..88a69a1a 100644 --- a/apps/web/src/wm/desktop.tsx +++ b/apps/web/src/wm/desktop.tsx @@ -18,6 +18,7 @@ import { useArrangedWidgets, useElementSize } from "webwm/react"; import { type Frame as FrameApi, FrameContext, HostContext, type Shell, ShellContext } from "@codeworksh/widget/view"; import { makeHost } from "../kernel/host"; +import { iconOf } from "../shell/icons"; import { builtins, registry } from "../widgets"; import { type Chrome, chromeAtom, chromeOf, focusedAtom, tabOwnerAtom } from "./chrome"; import { Divider } from "./divider"; @@ -27,7 +28,9 @@ import { add, deskAtom, type Instance, remove, retarget, focus as raise, update import { resolve } from "./resolver"; import { deleteWorkspace, + MAX_WORKSPACES, updateWorkspace, + useCreateWorkspace, useWorkspaces, type Workspace, workspaceRoute, @@ -76,7 +79,7 @@ function Host({ instanceId, kind, shell, decorate, children }: HostProps) { const frame = useMemo( () => ({ instanceId, - setTitle: (title) => decorate(instanceId, { title }), + setTitle: (title, lead) => decorate(instanceId, { title, lead }), setIcon: (icon) => decorate(instanceId, { icon }), setTab: (tab) => decorate(instanceId, { tab }), }), @@ -114,6 +117,7 @@ export function Desktop({ workspace }: DesktopProps) { const navigate = useNavigate(); const saveWorkspace = useAtomSet(updateWorkspace); const removeWorkspace = useAtomSet(deleteWorkspace, { mode: "promise" }); + const createWorkspace = useCreateWorkspace(); const { open: requested, mode: requestedMode } = useSearch({ strict: false }); const { workspaceId: routed } = useParams({ strict: false }); // Local edits ahead of the database, so dragging the divider never waits on a round trip. @@ -218,7 +222,8 @@ export function Desktop({ workspace }: DesktopProps) { const previous = current.get(instanceId) ?? {}; const next = { ...previous, ...patch }; const sameTab = next.tab?.title === previous.tab?.title && next.tab?.subtitle === previous.tab?.subtitle; - if (next.title === previous.title && next.icon === previous.icon && sameTab) return current; + if (next.title === previous.title && next.lead === previous.lead && next.icon === previous.icon && sameTab) + return current; return new Map(current).set(instanceId, next); }), [], @@ -233,26 +238,50 @@ export function Desktop({ workspace }: DesktopProps) { return next; }); - const close = (instanceId: string) => { - const closing = byId.get(instanceId); - setDesk((current) => - current.instances.some(({ id }) => id === instanceId) ? remove(current, instanceId) : current, - ); - undecorate(instanceId); - // An auto workspace exists to show what was opened into it, so it goes with its last frame. + // An auto workspace exists to show what was opened into it, so it goes with its last frame. + const leaving = (instanceId: string, to: { readonly to: "/" } | ReturnType) => { + const leaver = byId.get(instanceId); const emptied = - closing !== undefined && - (closing.tags & activeTags) !== 0 && + leaver !== undefined && + (leaver.tags & activeTags) !== 0 && !instances.some((instance) => instance.id !== instanceId && (instance.tags & activeTags) !== 0); // Leaving only once the delete lands: leaving first unmounts this and drops the list's refresh. if (workspace.auto && emptied) { void removeWorkspace({ payload: { id: workspace.id }, reactivityKeys: workspacesKey }).then( - () => navigate({ to: "/" }), + () => navigate(to), () => undefined, ); } }; + const close = (instanceId: string) => { + leaving(instanceId, { to: "/" }); + setDesk((current) => + current.instances.some(({ id }) => id === instanceId) ? remove(current, instanceId) : current, + ); + undecorate(instanceId); + }; + + // Moves a frame off this workspace onto another, focused there; this workspace stays on screen. + const move = (instanceId: string, destination: Workspace) => { + leaving(instanceId, workspaceRoute(destination)); + setDrag(null); + setDesk((current) => raise(update(current, instanceId, { tags: workspaceTag(destination) }), instanceId)); + }; + + // A new workspace is auto, so it goes once the frame leaves it; it is named like the frame's tab would be. + const moveNew = async (instanceId: string) => { + const instance = byId.get(instanceId); + if (instance === undefined) return; + const name = chrome.get(instanceId)?.tab?.title ?? chromeOf(instance, chrome.get(instanceId)).title; + const created = await createWorkspace({ name, icon: "app-window", auto: true }).catch(() => undefined); + if (created !== undefined) move(instanceId, created); + }; + + const destinations = workspaces + .filter((candidate) => candidate.id !== workspace.id) + .map(({ id, name, icon }) => ({ id, name, icon: iconOf(icon) })); + /** The shell API as seen by one widget: the default target, and the opener of what it opens. */ const shellFor = (caller: string): Shell => ({ open(address, { kind, placement = "next", mode = "replace", params } = {}) { @@ -354,11 +383,12 @@ export function Desktop({ workspace }: DesktopProps) { ].map(({ widget, rect, collapsed, hidden }) => { const instance = byId.get(widget.id); if (instance === undefined) return null; - const { title, icon, body } = describe(instance, chrome.get(widget.id)); + const { title, lead, icon, body } = describe(instance, chrome.get(widget.id)); return ( focusWidget(widget.id)} onPin={() => togglePin(widget.id)} onMaximize={() => toggleMax(widget.id)} + destinations={destinations} + onMove={(workspaceId) => { + const destination = workspaces.find((candidate) => candidate.id === workspaceId); + if (destination !== undefined) move(widget.id, destination); + }} + onMoveNew={workspaces.length < MAX_WORKSPACES ? () => void moveNew(widget.id) : undefined} onClose={() => close(widget.id)} onDragStart={() => setDrag({ widgetId: widget.id, deltaX: 0, deltaY: 0, target: null })} onDragMove={({ clientX, clientY, deltaX, deltaY }) => diff --git a/apps/web/src/wm/frame.tsx b/apps/web/src/wm/frame.tsx index 5fac91b3..19ac0692 100644 --- a/apps/web/src/wm/frame.tsx +++ b/apps/web/src/wm/frame.tsx @@ -1,13 +1,30 @@ -import { GripVertical, Maximize2, Minimize2, Pin, PinOff, X, type LucideIcon } from "lucide-react"; +import { AppWindow, GripVertical, Maximize2, Minimize2, Pin, PinOff, Plus, X, type LucideIcon } from "lucide-react"; import type { ReactNode } from "react"; import type { Rect } from "webwm"; import { useDragHandle } from "webwm/react"; import type { DragMovement } from "webwm/dom"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Title } from "./title"; +/** A workspace a frame can move to. */ +export interface Destination { + readonly id: string; + readonly name: string; + readonly icon: LucideIcon; +} + interface FrameProps { readonly title: string; + /** Before the title, quieter: what the title belongs to. */ + readonly lead?: string | undefined; readonly icon?: LucideIcon | undefined; readonly children?: ReactNode; readonly rect: Rect; @@ -22,9 +39,14 @@ interface FrameProps { readonly offset: { readonly deltaX: number; readonly deltaY: number } | null; /** Ease into new slots; off while the column divider is dragged so frames track the pointer. */ readonly animate: boolean; + /** The other workspaces, listed by the move menu. */ + readonly destinations: readonly Destination[]; readonly onFocus: () => void; readonly onPin: () => void; readonly onMaximize: () => void; + readonly onMove: (workspaceId: string) => void; + /** Moves the frame to a new workspace; unset while all workspaces are in use. */ + readonly onMoveNew: (() => void) | undefined; readonly onClose: () => void; readonly onDragStart: () => void; readonly onDragMove: (movement: DragMovement) => void; @@ -36,7 +58,7 @@ interface FrameProps { export const FRAME_HEADER_HEIGHT = 46; export function Frame(props: FrameProps) { - const { title, icon: Icon, rect, collapsed, focused, pinned, maximized, canDrag, offset, animate } = props; + const { title, lead, icon: Icon, rect, collapsed, focused, pinned, maximized, canDrag, offset, animate } = props; const handleRef = useDragHandle({ canDrag: () => props.canDrag, onDragStart: props.onDragStart, @@ -68,6 +90,7 @@ export function Frame(props: FrameProps) { aria-hidden /> {Icon && } + {lead !== undefined && } </div> <Action @@ -82,6 +105,7 @@ export function Frame(props: FrameProps) { active={false} onClick={props.onMaximize} /> + <Move destinations={props.destinations} onMove={props.onMove} onMoveNew={props.onMoveNew} /> <Action icon={X} label="Close" active={false} onClick={props.onClose} /> </header> {/* A collapsed frame keeps its widget mounted, so it keeps working and keeps its state. */} @@ -93,6 +117,57 @@ export function Frame(props: FrameProps) { ); } +/** Up to a third of the header, so the title it leads keeps the room. */ +function Lead({ text }: { readonly text: string }) { + return ( + <span className="flex max-w-1/3 shrink-0 items-center gap-1 text-ink-muted/70" title={text}> + <span className="truncate">{text}</span> + <span className="text-ink-muted/40" aria-hidden> + / + </span> + </span> + ); +} + +function Move({ + destinations, + onMove, + onMoveNew, +}: { + readonly destinations: readonly Destination[]; + readonly onMove: (workspaceId: string) => void; + readonly onMoveNew: (() => void) | undefined; +}) { + return ( + <DropdownMenu> + <DropdownMenuTrigger + aria-label="Move" + title="Move" + className={`${actionClass} data-[state=open]:bg-ink/8 data-[state=open]:text-ink`} + > + <AppWindow className="size-3.5" strokeWidth={1.75} /> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="min-w-48"> + <DropdownMenuLabel>Move</DropdownMenuLabel> + {destinations.map(({ id, name, icon: Icon }) => ( + <DropdownMenuItem key={id} onSelect={() => onMove(id)}> + <Icon strokeWidth={1.75} /> + <span className="truncate">{name}</span> + </DropdownMenuItem> + ))} + {destinations.length > 0 && <DropdownMenuSeparator />} + <DropdownMenuItem disabled={onMoveNew === undefined} onSelect={() => onMoveNew?.()}> + <Plus strokeWidth={1.75} /> + <span>New Workspace</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); +} + +const actionClass = + "grid size-6 place-items-center rounded-md text-ink-muted/60 transition-colors hover:bg-ink/8 hover:text-ink aria-pressed:text-ink"; + interface ActionProps { readonly icon: LucideIcon; readonly label: string; @@ -107,7 +182,7 @@ function Action({ icon: Icon, label, active, onClick }: ActionProps) { aria-label={label} title={label} aria-pressed={active} - className="grid size-6 place-items-center rounded-md text-ink-muted/60 transition-colors hover:bg-ink/8 hover:text-ink aria-pressed:text-ink" + className={actionClass} onClick={onClick} > <Icon className="size-3.5" strokeWidth={1.75} /> diff --git a/apps/web/src/wm/overflow.tsx b/apps/web/src/wm/overflow.tsx index 8d3e4895..3687ea9d 100644 --- a/apps/web/src/wm/overflow.tsx +++ b/apps/web/src/wm/overflow.tsx @@ -11,6 +11,7 @@ import { export interface Hidden { readonly id: string; readonly title: string; + readonly lead?: string | undefined; readonly icon?: LucideIcon | undefined; } @@ -38,10 +39,13 @@ export function Overflow({ <span className="font-semibold">+{hidden.length} more</span> </DropdownMenuTrigger> <DropdownMenuContent side="top" align="start" className="max-h-80 min-w-56 overflow-y-auto"> - {hidden.map(({ id, title, icon: Icon }) => ( + {hidden.map(({ id, title, lead, icon: Icon }) => ( <DropdownMenuItem key={id} onSelect={() => onPick(id)}> {Icon && <Icon strokeWidth={1.75} />} - <span className="truncate">{title}</span> + <span className="truncate"> + {lead !== undefined && <span className="text-ink-muted/70">{lead} / </span>} + {title} + </span> </DropdownMenuItem> ))} </DropdownMenuContent> diff --git a/apps/widget/src/view/shell.tsx b/apps/widget/src/view/shell.tsx index 81f83c32..4567fc84 100644 --- a/apps/widget/src/view/shell.tsx +++ b/apps/widget/src/view/shell.tsx @@ -43,8 +43,11 @@ export interface Tab { export interface Frame { readonly instanceId: string; - /** Replaces the default title; a title too long for the frame is shortened in the middle. */ - readonly setTitle: (title: string) => void; + /** + * Replaces the default title; a title too long for the frame is shortened in the middle. + * `lead` goes before it, quieter, for what the title belongs to (a session's project). + */ + readonly setTitle: (title: string, lead?: string) => void; /** Replaces the default icon. */ readonly setIcon: (icon: LucideIcon) => void; /** From 9fb2b98bbb884dcc98aae7fbe175db9f6fa7b661 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 17 Oct 2026 16:31:46 +0530 Subject: [PATCH 06/20] feat(web): settle frames without tweening and sharpen UI motion Frames no longer tween from the desktop's first 0x0 measurement into their slots when a workspace opens, nor trail the window edge on resize. Easing curves are stronger, pressable buttons scale on press through a shared `press` utility, the Cmd+K palette opens without animation, and reduced motion is honoured. The Move menu lists workspaces with one window icon and their Cmd+N number, at a fixed width so long names ellipsize. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- apps/web/src/components/composer.tsx | 2 +- apps/web/src/components/model.tsx | 2 +- apps/web/src/components/ui/button.tsx | 2 +- apps/web/src/components/ui/sheet.tsx | 2 +- apps/web/src/components/ui/sidebar.tsx | 8 ++++---- apps/web/src/shell/palette.tsx | 3 ++- apps/web/src/shell/titlebar.tsx | 2 +- apps/web/src/style.css | 18 ++++++++++++++++++ apps/web/src/wm/desktop.tsx | 22 ++++++++++++++++------ apps/web/src/wm/frame.tsx | 16 ++++++++++------ apps/web/src/wm/overflow.tsx | 2 +- 11 files changed, 56 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/composer.tsx b/apps/web/src/components/composer.tsx index efb36e4c..57bddc1d 100644 --- a/apps/web/src/components/composer.tsx +++ b/apps/web/src/components/composer.tsx @@ -102,7 +102,7 @@ export function ComposerButton({ title={label} disabled={disabled} onClick={onClick} - className="grid size-8 shrink-0 place-items-center rounded-full bg-brand text-brand-ink shadow-xs transition-[opacity,scale,filter] duration-150 enabled:hover:scale-105 enabled:hover:brightness-110 enabled:active:scale-100 disabled:opacity-64" + className="grid size-8 shrink-0 place-items-center rounded-full bg-brand text-brand-ink shadow-xs transition-[opacity,scale,filter] duration-150 ease-out enabled:hover:brightness-110 enabled:active:scale-[0.97] disabled:opacity-64" > {children} </button> diff --git a/apps/web/src/components/model.tsx b/apps/web/src/components/model.tsx index 47582f8d..23f065a0 100644 --- a/apps/web/src/components/model.tsx +++ b/apps/web/src/components/model.tsx @@ -61,7 +61,7 @@ const modelsAtom = Atom.family((hostDir: string | undefined) => // 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 transition-[background-color,color,scale] hover:bg-ink/5 hover:text-ink focus-visible:bg-ink/5 active:scale-[0.97] data-[state=open]:bg-ink/5 data-[state=open]:text-ink"; + "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"; /** A model's name and its provider's, when the list has it. */ const lookup = <P extends ModelProvider>(providers: ReadonlyArray<P>, ref: ModelRef) => { diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 502b0ffa..1c0db130 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -4,7 +4,7 @@ import { cn } from "@/lib/utils"; import { Slot } from "radix-ui"; const buttonVariants = cva( - "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow,scale] outline-none active:scale-[0.97] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx index b3617195..eefffc01 100644 --- a/apps/web/src/components/ui/sheet.tsx +++ b/apps/web/src/components/ui/sheet.tsx @@ -50,7 +50,7 @@ function SheetContent({ <SheetPrimitive.Content data-slot="sheet-content" className={cn( - "fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500", + "fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-out data-[state=closed]:animate-out data-[state=closed]:duration-200 data-[state=open]:animate-in data-[state=open]:duration-250", side === "right" && "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", side === "left" && diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 01b801e0..af37dc19 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -250,7 +250,7 @@ function Sidebar({ <div data-slot="sidebar-gap" className={cn( - "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear in-data-resizing:transition-none", + "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-out in-data-resizing:transition-none", "group-data-[collapsible=offcanvas]:w-0", "group-data-[side=right]:rotate-180", variant === "floating" || variant === "inset" @@ -261,7 +261,7 @@ function Sidebar({ <div data-slot="sidebar-container" className={cn( - "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear in-data-resizing:transition-none md:flex", + "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-out in-data-resizing:transition-none md:flex", side === "left" ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", @@ -360,7 +360,7 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { dragged.current = false; }} className={cn( - "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex", + "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-[left,right] ease-out group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex", resizable ? "cursor-col-resize" : "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize [[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", @@ -469,7 +469,7 @@ function SidebarGroupLabel({ data-slot="sidebar-group-label" data-sidebar="group-label" className={cn( - "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-out focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", className, )} diff --git a/apps/web/src/shell/palette.tsx b/apps/web/src/shell/palette.tsx index 3511449d..03a12cbf 100644 --- a/apps/web/src/shell/palette.tsx +++ b/apps/web/src/shell/palette.tsx @@ -64,7 +64,8 @@ export function Palette() { open={open} onOpenChange={show} showCloseButton={false} - className="top-[20%] translate-y-0 sm:max-w-xl" + // No open/close animation: it is keyboard-opened and used many times a day. + className="top-[20%] translate-y-0 data-[state=closed]:animate-none! data-[state=open]:animate-none! sm:max-w-xl" command={{ value, disablePointerSelection: true, diff --git a/apps/web/src/shell/titlebar.tsx b/apps/web/src/shell/titlebar.tsx index cbed3d98..21681001 100644 --- a/apps/web/src/shell/titlebar.tsx +++ b/apps/web/src/shell/titlebar.tsx @@ -13,7 +13,7 @@ export const insetTitleBar = window.desktopBridge?.platform === "darwin"; /** A square title-bar button; it opts out of the window's drag region so it can be clicked. */ export const chromeButton = - "grid size-7 shrink-0 place-items-center rounded-[10px] text-ink-muted transition-colors [-webkit-app-region:no-drag] hover:bg-ink/5 hover:text-ink disabled:opacity-40 disabled:hover:bg-transparent"; + "grid size-7 shrink-0 place-items-center rounded-[10px] text-ink-muted press [-webkit-app-region:no-drag] hover:bg-ink/5 hover:text-ink disabled:opacity-40 disabled:hover:bg-transparent"; export function BackButton() { const router = useRouter(); diff --git a/apps/web/src/style.css b/apps/web/src/style.css index dabd4c78..ff1d02f2 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -34,6 +34,9 @@ /* The platform's own faces (SF Pro and SF Mono on macOS), so the app reads as native. */ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; --font-mono: ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; + /* Tailwind's built-in curves are too weak for UI motion. */ + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); + --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); } @theme inline { @@ -92,6 +95,11 @@ box-shadow: var(--cw-dialog-shadow); } +/* Pressable icon and trigger buttons: a slight press-in on top of their hover colors. */ +@utility press { + @apply transition-[background-color,color,scale] duration-150 active:scale-[0.97]; +} + @utility dialog-backdrop { @apply bg-desktop/60 backdrop-blur-[4px]; } @@ -102,6 +110,16 @@ transition: none !important; } +/* Reduced motion: transitions and animations finish instantly, so nothing travels across the screen. */ +@media (prefers-reduced-motion: reduce) { + *, + ::before, + ::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} + @layer base { /* Tailwind v4 borders default to the text colour; shadcn components expect the soft edge. */ *, diff --git a/apps/web/src/wm/desktop.tsx b/apps/web/src/wm/desktop.tsx index 88a69a1a..9a95ab7f 100644 --- a/apps/web/src/wm/desktop.tsx +++ b/apps/web/src/wm/desktop.tsx @@ -18,7 +18,6 @@ import { useArrangedWidgets, useElementSize } from "webwm/react"; import { type Frame as FrameApi, FrameContext, HostContext, type Shell, ShellContext } from "@codeworksh/widget/view"; import { makeHost } from "../kernel/host"; -import { iconOf } from "../shell/icons"; import { builtins, registry } from "../widgets"; import { type Chrome, chromeAtom, chromeOf, focusedAtom, tabOwnerAtom } from "./chrome"; import { Divider } from "./divider"; @@ -127,6 +126,12 @@ export function Desktop({ workspace }: DesktopProps) { const [drag, setDrag] = useState<Drag | null>(null); const [resizing, setResizing] = useState(false); const [ref, size] = useElementSize<HTMLDivElement>(); + // A window resize must not tween: frames would trail the window edge by the transition's length. + const lastSize = useRef(size); + const resized = lastSize.current !== size; + useEffect(() => { + lastSize.current = size; + }, [size]); const surfaceRef = useRef<HTMLDivElement>(null); const activeTags = workspaceTag(workspace); const { instances, order } = desk; @@ -278,9 +283,10 @@ export function Desktop({ workspace }: DesktopProps) { if (created !== undefined) move(instanceId, created); }; - const destinations = workspaces - .filter((candidate) => candidate.id !== workspace.id) - .map(({ id, name, icon }) => ({ id, name, icon: iconOf(icon) })); + // Numbered by switcher position (the Cmd+N key), so same-named workspaces still tell apart. + const destinations = workspaces.flatMap(({ id, name }, index) => + id === workspace.id ? [] : [{ id, name, position: index + 1 }], + ); /** The shell API as seen by one widget: the default target, and the opener of what it opens. */ const shellFor = (caller: string): Shell => ({ @@ -363,12 +369,16 @@ export function Desktop({ workspace }: DesktopProps) { return next; }); + const mainClass = "box-border h-full overflow-auto p-1 [scrollbar-gutter:stable_both-edges]"; + // The first measurement is 0×0; frames committed at it would tween from the origin into their slots. + if (size.width === 0 || size.height === 0) return <main className={mainClass} ref={ref} />; + // With each frame's p-1 inset, p-1 here gives even 8px gaps at edges and between frames. // The scrollbar gutter is always reserved (on both edges, so gaps stay even): if a // scrollbar could take width only when it shows, it would shrink the layout, the // overflow would vanish with it, and the layout would jitter between both. return ( - <main className="box-border h-full overflow-auto p-1 [scrollbar-gutter:stable_both-edges]" ref={ref}> + <main className={mainClass} ref={ref}> <div ref={surfaceRef} className="relative" @@ -398,7 +408,7 @@ export function Desktop({ workspace }: DesktopProps) { maximized={layout === "monocle"} canDrag={layout === "tile" && !pinned.has(widget.id)} offset={drag?.widgetId === widget.id ? drag : null} - animate={!resizing} + animate={!resizing && !resized} onFocus={() => focusWidget(widget.id)} onPin={() => togglePin(widget.id)} onMaximize={() => toggleMax(widget.id)} diff --git a/apps/web/src/wm/frame.tsx b/apps/web/src/wm/frame.tsx index 19ac0692..3931e234 100644 --- a/apps/web/src/wm/frame.tsx +++ b/apps/web/src/wm/frame.tsx @@ -10,15 +10,18 @@ import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, + DropdownMenuShortcut, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { shortcutLabel } from "../shell/switcher"; import { Title } from "./title"; /** A workspace a frame can move to. */ export interface Destination { readonly id: string; readonly name: string; - readonly icon: LucideIcon; + /** Its place in the switcher, which is also its Cmd+N key. */ + readonly position: number; } interface FrameProps { @@ -147,12 +150,13 @@ function Move({ > <AppWindow className="size-3.5" strokeWidth={1.75} /> </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="min-w-48"> + <DropdownMenuContent align="end" className="w-64"> <DropdownMenuLabel>Move</DropdownMenuLabel> - {destinations.map(({ id, name, icon: Icon }) => ( - <DropdownMenuItem key={id} onSelect={() => onMove(id)}> - <Icon strokeWidth={1.75} /> + {destinations.map(({ id, name, position }) => ( + <DropdownMenuItem key={id} title={name} onSelect={() => onMove(id)}> + <AppWindow strokeWidth={1.75} /> <span className="truncate">{name}</span> + <DropdownMenuShortcut>{shortcutLabel(position)}</DropdownMenuShortcut> </DropdownMenuItem> ))} {destinations.length > 0 && <DropdownMenuSeparator />} @@ -166,7 +170,7 @@ function Move({ } const actionClass = - "grid size-6 place-items-center rounded-md text-ink-muted/60 transition-colors hover:bg-ink/8 hover:text-ink aria-pressed:text-ink"; + "grid size-6 place-items-center rounded-md text-ink-muted/60 press hover:bg-ink/8 hover:text-ink aria-pressed:text-ink"; interface ActionProps { readonly icon: LucideIcon; diff --git a/apps/web/src/wm/overflow.tsx b/apps/web/src/wm/overflow.tsx index 3687ea9d..ba439729 100644 --- a/apps/web/src/wm/overflow.tsx +++ b/apps/web/src/wm/overflow.tsx @@ -38,7 +38,7 @@ export function Overflow({ <ChevronUp className="size-3.5 shrink-0" strokeWidth={1.75} aria-hidden /> <span className="font-semibold">+{hidden.length} more</span> </DropdownMenuTrigger> - <DropdownMenuContent side="top" align="start" className="max-h-80 min-w-56 overflow-y-auto"> + <DropdownMenuContent side="top" align="start" className="max-h-80 w-64 overflow-y-auto"> {hidden.map(({ id, title, lead, icon: Icon }) => ( <DropdownMenuItem key={id} onSelect={() => onPick(id)}> {Icon && <Icon strokeWidth={1.75} />} From a024f77753438493f0d16f4800871137e6d1a96d Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 17 Oct 2026 16:31:52 +0530 Subject: [PATCH 07/20] feat(web): highlight code blocks in the session feed Fenced code in a session renders as a framed block with its language, a copy button and Shiki token colours. Each palette maps to a Shiki theme, the same one where Shiki ships it; only token colours are taken, the block's background stays the app's. Grammars and themes load on demand through one shared highlighter, and highlighting is synchronous once loaded so streaming blocks never flash. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- apps/web/package.json | 2 + apps/web/src/widgets/session/highlight.tsx | 114 +++++++++++++++++++++ apps/web/src/widgets/session/markdown.tsx | 76 ++++++++++++-- pnpm-lock.yaml | 6 ++ 4 files changed, 191 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/widgets/session/highlight.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 987a184d..4197a8f0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "effect": "4.0.1", + "hast-util-to-jsx-runtime": "2.3.6", "lucide-react": "^1.52.0", "radix-ui": "^1.7.0", "react": "^19.3.0", @@ -30,6 +31,7 @@ "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "scheduler": "^0.27.0", + "shiki": "4.4.3", "tailwind-merge": "^3.7.0", "webwm": "https://github.com/codeworksh/webwm/releases/download/v0.2.0/webwm-0.2.0.tgz" }, diff --git a/apps/web/src/widgets/session/highlight.tsx b/apps/web/src/widgets/session/highlight.tsx new file mode 100644 index 00000000..a71242f1 --- /dev/null +++ b/apps/web/src/widgets/session/highlight.tsx @@ -0,0 +1,114 @@ +import { toJsxRuntime } from "hast-util-to-jsx-runtime"; +import { useEffect, useMemo, useReducer } from "react"; +import { Fragment, jsx, jsxs } from "react/jsx-runtime"; +import { + type BundledLanguage, + type BundledTheme, + bundledLanguages, + bundledLanguagesAlias, + createHighlighter, + type Highlighter, +} from "shiki"; + +import type { ThemeId } from "../../theme"; + +/** + * The Shiki theme whose token colours go with each palette: the same theme where Shiki ships it, + * else the closest in mood. Only token colours are used; the code block's background is ours. + */ +const SHIKI_THEMES: Record<ThemeId, BundledTheme> = { + "tokyo-night": "tokyo-night", + catppuccin: "catppuccin-mocha", + "catppuccin-latte": "catppuccin-latte", + gruvbox: "gruvbox-dark-medium", + everforest: "everforest-dark", + kanagawa: "kanagawa-wave", + nord: "nord", + "rose-pine": "rose-pine-dawn", + "last-horizon": "horizon", + white: "github-light", + "flexoki-light": "vitesse-light", + lupine: "one-light", + "matte-black": "github-dark-default", + vantablack: "min-dark", + hackerman: "github-dark-high-contrast", + lumon: "poimandres", + ethereal: "one-dark-pro", + miasma: "gruvbox-dark-soft", + "osaka-jade": "vitesse-dark", + "retro-82": "synthwave-84", + ristretto: "monokai", + solitude: "github-dark-dimmed", +}; + +type Root = ReturnType<Highlighter["codeToHast"]>; + +/** A fence's language as Shiki knows it; undefined for none or one it has no grammar for. */ +export function languageOf(name: string | undefined): BundledLanguage | undefined { + const lower = name?.toLowerCase(); + return lower !== undefined && (lower in bundledLanguages || lower in bundledLanguagesAlias) + ? (lower as BundledLanguage) + : undefined; +} + +// One highlighter for the app, with grammars and themes loaded as code blocks ask for them. +let highlighter: Highlighter | undefined; +let creating: Promise<Highlighter> | undefined; +const loading = new Map<string, Promise<void>>(); +const listeners = new Set<() => void>(); + +const notify = () => { + for (const listener of listeners) listener(); +}; + +function load(kind: "lang" | "theme", name: string) { + const key = `${kind}:${name}`; + if (loading.has(key)) return; + creating ??= createHighlighter({ themes: [], langs: [] }).then((created) => (highlighter = created)); + const task = creating + .then((ready) => + kind === "lang" ? ready.loadLanguage(name as BundledLanguage) : ready.loadTheme(name as BundledTheme), + ) + .then(notify) + .catch(() => undefined); + loading.set(key, task); +} + +/** Whether `code` can be highlighted right now; if not, loading starts and subscribers re-render. */ +function ready(lang: BundledLanguage, theme: BundledTheme) { + const hasLang = highlighter?.getLoadedLanguages().includes(lang) === true; + const hasTheme = highlighter?.getLoadedThemes().includes(theme) === true; + if (!hasLang) load("lang", lang); + if (!hasTheme) load("theme", theme); + return hasLang && hasTheme; +} + +/** + * `code` highlighted for the palette, as Shiki's hast tree, or undefined until its grammar and + * theme have loaded (the caller shows the plain text meanwhile). Synchronous once loaded, so a + * streaming block re-highlights in the same render as its text changes, with nothing to flash. + */ +export function useHighlight(code: string, lang: BundledLanguage | undefined, theme: ThemeId): Root | undefined { + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => { + listeners.add(rerender); + return () => { + listeners.delete(rerender); + }; + }, []); + const shikiTheme = SHIKI_THEMES[theme]; + const available = lang !== undefined && ready(lang, shikiTheme); + return useMemo( + () => (available && lang !== undefined ? highlighter?.codeToHast(code, { lang, theme: shikiTheme }) : undefined), + [available, code, lang, shikiTheme], + ); +} + +const runtime = { Fragment, jsx, jsxs }; + +/** The highlighted lines alone: Shiki's own `pre` (and its background) is left out. */ +export function HighlightedCode({ root }: { readonly root: Root }) { + const pre = root.children[0]; + const code = pre?.type === "element" ? pre.children[0] : undefined; + return code?.type === "element" ? toJsxRuntime(code, runtime) : toJsxRuntime(root, runtime); +} diff --git a/apps/web/src/widgets/session/markdown.tsx b/apps/web/src/widgets/session/markdown.tsx index 6218a19c..990d0af5 100644 --- a/apps/web/src/widgets/session/markdown.tsx +++ b/apps/web/src/widgets/session/markdown.tsx @@ -1,8 +1,22 @@ -import { type ComponentProps, createElement, type JSX, memo, useMemo } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import { Check, Copy } from "lucide-react"; +import { + type ComponentProps, + createElement, + isValidElement, + type JSX, + memo, + type ReactNode, + useEffect, + useMemo, + useState, +} from "react"; import type { Components, ExtraProps } from "react-markdown"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { themeAtom } from "../../theme"; +import { HighlightedCode, languageOf, useHighlight } from "./highlight"; import { incrementalMarkdown } from "./incremental"; /** An element with fixed classes; react-markdown's `node` prop is dropped so it never reaches the DOM. */ @@ -14,13 +28,60 @@ function styled<Tag extends keyof JSX.IntrinsicElements>(tag: Tag, className: st }; } -const Pre = styled( - "pre", - "my-2 overflow-x-auto rounded-lg border border-edge bg-desktop/60 px-3 py-2 font-mono text-[12px] leading-relaxed [&>code]:border-0 [&>code]:bg-transparent [&>code]:p-0", -); +/** A fenced block's language and text, from the `code` element react-markdown puts in a `pre`. */ +function fence(children: ReactNode) { + if (!isValidElement<{ className?: string; children?: ReactNode }>(children)) return undefined; + const { className = "", children: text } = children.props; + if (typeof text !== "string") return undefined; + // The fence's trailing newline would render as an empty last line. + return { language: /language-(\S+)/.exec(className)?.[1], code: text.replace(/\n$/, "") }; +} + +function CopyButton({ text }: { readonly text: string }) { + const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 1200); + return () => clearTimeout(timer); + }, [copied]); + const Icon = copied ? Check : Copy; + return ( + <button + type="button" + aria-label={copied ? "Copied" : "Copy code"} + title="Copy code" + className="press grid size-6 place-items-center rounded-md text-ink-muted hover:bg-ink/8 hover:text-ink" + onClick={() => void navigator.clipboard.writeText(text).then(() => setCopied(true))} + > + <Icon className="size-3.5" strokeWidth={1.75} aria-hidden /> + </button> + ); +} + +/** A fenced code block: its language and a copy button above the code, highlighted for the palette. */ +function CodeBlock(props: ComponentProps<"pre"> & ExtraProps) { + const block = fence(props.children); + const theme = useAtomValue(themeAtom); + const language = languageOf(block?.language); + const highlighted = useHighlight(block?.code ?? "", language, theme); + if (block === undefined) return <pre className={preClass}>{props.children}</pre>; + return ( + <div className="my-2 overflow-hidden rounded-lg border border-edge bg-desktop/60"> + <div className="flex h-7 items-center justify-between border-b border-edge pr-1 pl-3 text-[11px] text-ink-muted select-none"> + <span>{block.language ?? "text"}</span> + <CopyButton text={block.code} /> + </div> + <pre className={preClass}> + {highlighted === undefined ? <code>{block.code}</code> : <HighlightedCode root={highlighted} />} + </pre> + </div> + ); +} + +const preClass = "overflow-x-auto px-3 py-2 font-mono text-[12px] leading-relaxed"; const Table = styled("table", "border-collapse text-[13px]"); -// GitHub-flavoured markdown at reading size; inline code reads as a small chip, blocks scroll sideways. +// GitHub-flavoured markdown at reading size. const components: Components = { p: styled("p", "my-2 first:mt-0 last:mb-0"), ul: styled("ul", "my-2 list-disc pl-5"), @@ -31,7 +92,8 @@ const components: Components = { h3: styled("h3", "mt-3 mb-1.5 font-semibold"), strong: styled("strong", "font-semibold text-ink"), blockquote: styled("blockquote", "my-2 border-l-2 border-edge pl-3 text-ink-muted"), - pre: Pre, + pre: CodeBlock, + // Inline code reads as a small chip; inside a fence the block renders the text itself. code: styled("code", "rounded-md border border-edge bg-ink/5 px-1.5 py-px font-mono text-[12px] text-ink"), th: styled("th", "border border-edge px-2 py-1 text-left font-semibold"), td: styled("td", "border border-edge px-2 py-1"), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b493959..095d625f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -285,6 +285,9 @@ importers: effect: specifier: 4.0.1 version: 4.0.1 + hast-util-to-jsx-runtime: + specifier: 2.3.6 + version: 2.3.6 lucide-react: specifier: ^1.52.0 version: 1.52.0(@types/react@19.3.0)(react@19.3.0) @@ -306,6 +309,9 @@ importers: scheduler: specifier: ^0.27.0 version: 0.27.0 + shiki: + specifier: 4.4.3 + version: 4.4.3 tailwind-merge: specifier: ^3.7.0 version: 3.7.0 From d42de213b81fa88f14ef0f3fec48413ad46f5519 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 17 Oct 2026 16:33:09 +0530 Subject: [PATCH 08/20] add skills --- skills-lock.json | 66 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/skills-lock.json b/skills-lock.json index 5b444753..3b42c983 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,6 +1,72 @@ { "version": 1, "skills": { + "animate": { + "source": "emilkowalski/skills", + "sourceType": "github", + "skillPath": "skills/animate/SKILL.md", + "computedHash": "7598d33e7f60dd5c24531eaaa897621cc3bdb48e8050324211627973bc59c4ce" + }, + "animation-performance": { + "source": "emilkowalski/skills", + "sourceType": "github", + "skillPath": "skills/animation-performance/SKILL.md", + "computedHash": "b68ba704106e27b1f83df0cb817e09ec947abb76825d86eabdee2954b6c1bb84" + }, + "animation-vocabulary": { + "source": "emilkowalski/skills", + "sourceType": "github", + "skillPath": "skills/animation-vocabulary/SKILL.md", + "computedHash": "0058036865ecbb864fd0c4132efcc62425355d6e99c7e6ef6a936742d62a5dea" + }, + "apple-design": { + "source": "emilkowalski/skills", + "sourceType": "github", + "skillPath": "skills/apple-design/SKILL.md", + "computedHash": "0190417d092a0df447b000154e39733ea6d97efad6d3bec8a6ce3845aeb88914" + }, + "ask-sonner": { + "source": "emilkowalski/skills", + "sourceType": "github", + "skillPath": "skills/ask-sonner/SKILL.md", + "computedHash": "8bd257b845985f4b35b603b510a126604176521094af79c4b7347ffeb8a8f7ab" + }, + "break-ui": { + "source": "emilkowalski/skills", + "sourceType": "github", + "skillPath": "skills/break-ui/SKILL.md", + "computedHash": "60ada67662925f4df5e3c6ff41cac3ab241b66f8898100a5ffcd7ba7196cb868" + }, + "emil-design-eng": { + "source": "emilkowalski/skills", + "sourceType": "github", + "skillPath": "skills/emil-design-eng/SKILL.md", + "computedHash": "51f2537e04d15911b41d78e2de24c36a0e26d41beae15e82a954fe5496a5eda9" + }, + "find-animation-opportunities": { + "source": "emilkowalski/skills", + "sourceType": "github", + "skillPath": "skills/find-animation-opportunities/SKILL.md", + "computedHash": "465d087c41768e4f14ed640dd049036009f613feb55baaa5831b68b33178f9d2" + }, + "improve-animations": { + "source": "emilkowalski/skills", + "sourceType": "github", + "skillPath": "skills/improve-animations/SKILL.md", + "computedHash": "524ea241e41d58bbdd8e288700fe24f5725e2a2f3ae7186ad7cebf9055058356" + }, + "pick-ui-library": { + "source": "emilkowalski/skills", + "sourceType": "github", + "skillPath": "skills/pick-ui-library/SKILL.md", + "computedHash": "5d2a77bf0ecafbda85aa95fb49999b6d77945d9066ec83d970b62e5b388cd686" + }, + "review-animations": { + "source": "emilkowalski/skills", + "sourceType": "github", + "skillPath": "skills/review-animations/SKILL.md", + "computedHash": "b4e5f0f4fe3e2743c85bf7354108f48a0b9d502cb20ef5c4a9c195da91f93736" + }, "trees": { "source": "pierrecomputer/pierre", "sourceType": "github", From fa946278eaa2fcfa1aeba57f1df3cbafaad04c8d Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 17 Oct 2026 17:56:56 +0530 Subject: [PATCH 09/20] feat(web): keep the sessions sidebar beside workspaces The sessions sidebar now wraps every page from the root route, with one title bar shared by the start page and workspaces, so its open state and width carry across navigation. Clicking a session on a workspace opens it there, and the frame in front reads as current in the list. The start page's folder lives in an atom so the sidebar's "Add Project" works from anywhere. Settings takes the whole window instead: no sidebar, no workspace tabs, only a drag strip with a close button that returns to the last workspace, like Esc. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- apps/web/src/landing/landing.tsx | 164 +++++++++++++------------------ apps/web/src/landing/place.ts | 30 ++++++ apps/web/src/landing/sidebar.tsx | 81 +++++---------- apps/web/src/routes/__root.tsx | 30 ++++-- apps/web/src/settings/layout.tsx | 140 +++++++++++++++----------- apps/web/src/shell/shell.tsx | 17 +--- apps/web/src/shell/titlebar.tsx | 57 ++++++++--- apps/web/src/wm/open.ts | 15 ++- 8 files changed, 284 insertions(+), 250 deletions(-) create mode 100644 apps/web/src/landing/place.ts diff --git a/apps/web/src/landing/landing.tsx b/apps/web/src/landing/landing.tsx index af52a8c1..bd1851ad 100644 --- a/apps/web/src/landing/landing.tsx +++ b/apps/web/src/landing/landing.tsx @@ -1,7 +1,7 @@ -import { RegistryContext, useAtomRefresh } from "@effect/atom-react"; +import { RegistryContext, useAtomRefresh, useAtomValue } from "@effect/atom-react"; import { Option, Schema } from "effect"; import { ArrowUp, ChevronDown, Folder, FolderPlus } from "lucide-react"; -import { type CSSProperties, useContext, useMemo, useState } from "react"; +import { useContext, useMemo, useState } from "react"; import { DropdownMenu, @@ -11,30 +11,19 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Composer, ComposerButton } from "@/components/composer"; -import { SidebarProvider } from "@/components/ui/sidebar"; 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 { projectsAtom, SessionsSidebar, TitleBar } from "./sidebar"; +import { TitleBar } from "../shell/titlebar"; +import { placeAtom, placeLabel, usePickFolder } from "./place"; +import { projectsAtom } from "../widgets/session/projects"; import { Wordmark } from "./wordmark"; -const bridge = window.desktopBridge; - const encodeCreate = Schema.encodeSync(Json.Create); const decodeCreated = Schema.decodeUnknownOption(Json.Created); -/** - * Where a new project lives: nowhere yet, or a folder on disk. Memory sessions are not offered - * until they outlive the server that made them; the Session backend can still create them. - */ -type Place = { kind: "none" } | { kind: "folder"; path: string }; - -// A folder shows by its name alone; the trigger's tooltip carries the full path. -const placeLabel = (place: Place) => - place.kind === "none" ? "Select Project" : (place.path.split("/").findLast(Boolean) ?? place.path); - /** * The start page: pick where to work and send the first prompt. The Session backend creates the * session and sends the prompt; the new session then opens in a workspace. On failure the text stays. @@ -46,7 +35,7 @@ export function Landing() { const openElsewhere = useOpenElsewhere(); const refreshProjects = useAtomRefresh(projectsAtom); const [query, setQuery] = useState(""); - const [place, setPlace] = useState<Place>({ kind: "none" }); + const place = useAtomValue(placeAtom); const [pending, setPending] = useState(false); const [error, setError] = useState<string>(); // Remembered across restarts; an absent pick leaves it to settings. @@ -84,87 +73,70 @@ export function Landing() { await openElsewhere(sessionAddress(created.sessionId), placeLabel(place), "replace"); }; - // Only the desktop app can open the native folder dialog (Finder on macOS); it opens on the - // current folder, or home before one is picked. - const pickFolder = - bridge === undefined - ? undefined - : async () => { - const picked = await bridge.pickFolder(place.kind === "folder" ? place.path : bridge.home); - if (picked !== null) setPlace({ kind: "folder", path: picked }); - }; + const pickFolder = usePickFolder(); return ( - <SidebarProvider - className="h-full min-h-0" - style={{ "--sidebar-width": "17rem" } as CSSProperties} - widthStorageKey="codework.sidebar.width" - > - <SessionsSidebar project={hostDir} onAddProject={pickFolder} /> - <div className="flex min-w-0 flex-1 flex-col select-none"> - <TitleBar /> - <main className="relative flex min-h-0 flex-1 flex-col items-center overflow-auto px-4"> - <div className="flex-[0.8]" /> - <Wordmark className="pointer-events-none w-[min(88%,44rem)] shrink-0 text-brand opacity-15" /> - <div className="mt-10 w-full max-w-2xl shrink-0"> - <Composer - autoFocus - value={query} - onChange={setQuery} - onSubmit={() => void send()} - placeholder="Ask for changes, send follow-ups, or attach images" - controls={ - <> - <ModelPicker - hostDir={hostDir} - value={model} - onChange={(next) => setChoice({ ...choice, model: next })} - /> - <ThinkingPicker - hostDir={hostDir} - model={model} - value={thinking} - onChange={(level) => setChoice({ ...choice, thinkingLevel: level })} - /> - </> - } - actions={ - <ComposerButton label="Send" disabled={!canSend}> - <ArrowUp className="size-4" strokeWidth={2} aria-hidden /> - </ComposerButton> - } - tray={ - <> - <DropdownMenu> - <DropdownMenuTrigger - title={place.kind === "folder" ? place.path : undefined} - className="flex h-6 min-w-0 items-center gap-1 rounded-[8px] px-1.75 leading-4 outline-none transition-colors hover:bg-ink/5 hover:text-ink focus-visible:bg-ink/5 data-[state=open]:bg-ink/5 data-[state=open]:text-ink" + <div className="flex h-full min-w-0 flex-col select-none"> + <TitleBar active={undefined} /> + <main className="relative flex min-h-0 flex-1 flex-col items-center overflow-auto px-4"> + <div className="flex-[0.8]" /> + <Wordmark className="pointer-events-none w-[min(88%,44rem)] shrink-0 text-brand opacity-15" /> + <div className="mt-10 w-full max-w-2xl shrink-0"> + <Composer + autoFocus + value={query} + onChange={setQuery} + onSubmit={() => void send()} + placeholder="Ask for changes, send follow-ups, or attach images" + controls={ + <> + <ModelPicker + hostDir={hostDir} + value={model} + onChange={(next) => setChoice({ ...choice, model: next })} + /> + <ThinkingPicker + hostDir={hostDir} + model={model} + value={thinking} + onChange={(level) => setChoice({ ...choice, thinkingLevel: level })} + /> + </> + } + actions={ + <ComposerButton label="Send" disabled={!canSend}> + <ArrowUp className="size-4" strokeWidth={2} aria-hidden /> + </ComposerButton> + } + tray={ + <> + <DropdownMenu> + <DropdownMenuTrigger + title={place.kind === "folder" ? place.path : undefined} + className="flex h-6 min-w-0 items-center gap-1 rounded-[8px] px-1.75 leading-4 outline-none transition-colors hover:bg-ink/5 hover:text-ink focus-visible:bg-ink/5 data-[state=open]:bg-ink/5 data-[state=open]:text-ink" + > + <Folder className="size-3 shrink-0" strokeWidth={1.75} aria-hidden /> + <span className="truncate">{placeLabel(place)}</span> + <ChevronDown className="size-3 shrink-0 opacity-50" strokeWidth={2} aria-hidden /> + </DropdownMenuTrigger> + <DropdownMenuContent align="start" className="min-w-44"> + <DropdownMenuLabel>Project</DropdownMenuLabel> + <DropdownMenuItem + disabled={pickFolder === undefined} + onSelect={() => void pickFolder?.()} > - <Folder className="size-3 shrink-0" strokeWidth={1.75} aria-hidden /> - <span className="truncate">{placeLabel(place)}</span> - <ChevronDown className="size-3 shrink-0 opacity-50" strokeWidth={2} aria-hidden /> - </DropdownMenuTrigger> - <DropdownMenuContent align="start" className="min-w-44"> - <DropdownMenuLabel>Project</DropdownMenuLabel> - <DropdownMenuItem - disabled={pickFolder === undefined} - onSelect={() => void pickFolder?.()} - > - <FolderPlus strokeWidth={1.75} /> - New… - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - {error !== undefined && ( - <span className="truncate px-1.75 py-1 text-destructive">{error}</span> - )} - </> - } - /> - </div> - <div className="flex-1" /> - </main> - </div> - </SidebarProvider> + <FolderPlus strokeWidth={1.75} /> + New… + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + {error !== undefined && <span className="truncate px-1.75 py-1 text-destructive">{error}</span>} + </> + } + /> + </div> + <div className="flex-1" /> + </main> + </div> ); } diff --git a/apps/web/src/landing/place.ts b/apps/web/src/landing/place.ts new file mode 100644 index 00000000..aac0d5a6 --- /dev/null +++ b/apps/web/src/landing/place.ts @@ -0,0 +1,30 @@ +import { useAtom } from "@effect/atom-react"; +import { Atom } from "effect/reactivity"; + +/** + * Where a new project lives: nowhere yet, or a folder on disk. Memory sessions are not offered + * until they outlive the server that made them; the Session backend can still create them. + */ +export type Place = { kind: "none" } | { kind: "folder"; path: string }; + +// A folder shows by its name alone; the trigger's tooltip carries the full path. +export const placeLabel = (place: Place) => + place.kind === "none" ? "Select Project" : (place.path.split("/").findLast(Boolean) ?? place.path); + +/** The start page's folder, kept while the app runs so coming back to the page finds it again. */ +export const placeAtom = Atom.make<Place>({ kind: "none" }).pipe(Atom.keepAlive); + +const bridge = window.desktopBridge; + +/** + * Picks the start page's folder in the native dialog (Finder on macOS), which opens on the current + * folder, or home before one is picked. Only the desktop app can open it; absent elsewhere. + */ +export function usePickFolder() { + const [place, setPlace] = useAtom(placeAtom); + if (bridge === undefined) return undefined; + return async () => { + const picked = await bridge.pickFolder(place.kind === "folder" ? place.path : bridge.home); + if (picked !== null) setPlace({ kind: "folder", path: picked }); + }; +} diff --git a/apps/web/src/landing/sidebar.tsx b/apps/web/src/landing/sidebar.tsx index 826da366..a30e05a4 100644 --- a/apps/web/src/landing/sidebar.tsx +++ b/apps/web/src/landing/sidebar.tsx @@ -1,46 +1,41 @@ import { useAtomValue } from "@effect/atom-react"; +import { useNavigate, useParams } from "@tanstack/react-router"; import { Option } from "effect"; import { AsyncResult } from "effect/reactivity"; import { Plus, Search } from "lucide-react"; import { type MouseEvent, useState } from "react"; -import { - Sidebar, - SidebarContent, - SidebarHeader, - SidebarInput, - SidebarRail, - SidebarTrigger, - useSidebar, -} from "@/components/ui/sidebar"; -import { Server } from "../kernel/rpc"; +import { Sidebar, SidebarContent, SidebarHeader, SidebarInput, SidebarRail } from "@/components/ui/sidebar"; import { isMac, openMode } from "@codeworksh/widget/view"; import { sessionAddress } from "../widgets/session/address"; -import { Switcher } from "../shell/switcher"; -import { insetTitleBar, NewWorkspaceButton, SettingsButton } from "../shell/titlebar"; +import { projectsAtom } from "../widgets/session/projects"; +import { insetTitleBar } from "../shell/titlebar"; +import { focusedAtom } from "../wm/chrome"; +import { deskAtom } from "../wm/instances"; import { useOpenElsewhere } from "../wm/open"; -import { useWorkspaces } from "../wm/workspaces"; import { ago } from "../widgets/time"; import { Monogram } from "@/components/monogram"; - -/** The sidebar's sessions; refresh it once a new session exists. */ -export const projectsAtom = Server.query("projects.list", undefined); +import { placeAtom, usePickFolder } from "./place"; /** - * Recent sessions across every project, newest first. With none yet it invites adding a project, - * the folder the start page's composer then works in; `project` is that folder once picked. + * Recent sessions across every project, newest first, beside every page. With none yet it invites + * adding a project: the folder the start page's composer then works in. */ -export function SessionsSidebar({ - project, - onAddProject, -}: { - readonly project: string | undefined; - /** Absent outside the desktop app, which alone can open the folder dialog. */ - readonly onAddProject: (() => void) | undefined; -}) { +export function SessionsSidebar() { const result = useAtomValue(projectsAtom); const [query, setQuery] = useState(""); const openElsewhere = useOpenElsewhere(); + const place = useAtomValue(placeAtom); + const pickFolder = usePickFolder(); + const navigate = useNavigate(); + // The session in front on the workspace on screen reads as current. + const { workspaceId } = useParams({ strict: false }); + const { instances } = useAtomValue(deskAtom); + const focusedOn = useAtomValue(focusedAtom); + const focusedId = workspaceId === undefined ? undefined : focusedOn.get(workspaceId); + const current = instances.find((instance) => instance.id === focusedId)?.address; + // Picking lands on the start page, where the folder is used. + const addProject = pickFolder === undefined ? undefined : () => void pickFolder().then(() => navigate({ to: "/" })); // Primary and middle clicks open; a right click is left to the context menu. const open = (session: { id: string; project: { name: string } }, event: MouseEvent) => { if (event.button <= 1) void openElsewhere(sessionAddress(session.id), session.project.name, openMode(event)); @@ -89,13 +84,13 @@ export function SessionsSidebar({ <p className="px-2.5 py-2 text-[12px] text-ink-muted">No sessions match “{query}”.</p> ) : ( <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-[12px] text-ink-muted/70"> - {project === undefined && result.value.length === 0 ? ( + {place.kind === "none" && result.value.length === 0 ? ( <> <span>No Projects Yet</span> - {onAddProject !== undefined && ( + {addProject !== undefined && ( <button type="button" - onClick={onAddProject} + onClick={addProject} className="inline-flex items-center gap-1.5 rounded-md border border-edge px-2.5 py-1 text-[11px] font-medium text-ink-muted transition-colors outline-none hover:bg-ink/5 hover:text-ink focus-visible:bg-ink/5" > <Plus className="-mx-0.5 size-3" strokeWidth={2} aria-hidden /> @@ -115,7 +110,8 @@ export function SessionsSidebar({ <button type="button" title={`${session.project.path}\n${isMac ? "⌘" : "Ctrl"}-click opens it in a new frame`} - className="block w-full rounded-[8px] px-2.5 py-2 text-left transition-colors outline-none hover:bg-ink/5 focus-visible:bg-ink/5" + aria-current={sessionAddress(session.id) === current ? "true" : undefined} + className="block w-full rounded-[8px] px-2.5 py-2 text-left transition-colors outline-none hover:bg-ink/5 focus-visible:bg-ink/5 aria-[current=true]:bg-ink/8" onClick={(event) => open(session, event)} onAuxClick={(event) => open(session, event)} > @@ -138,28 +134,3 @@ export function SessionsSidebar({ </Sidebar> ); } - -/** - * The main area's title bar: the window's drag region with the sidebar toggle. With the sidebar - * hidden, or overlaid as a sheet in a narrow window, it sits in the window's corner, so it moves - * clear of the macOS traffic lights. - */ -export function TitleBar() { - const { open, isMobile } = useSidebar(); - const inCorner = !open || isMobile; - const workspaces = useWorkspaces(); - return ( - <header - className={`flex h-12 shrink-0 items-center gap-1.5 px-2 select-none [-webkit-app-region:drag] ${inCorner && insetTitleBar ? "pl-20" : ""}`} - > - <SidebarTrigger - title={`Toggle sidebar (${isMac ? "⌘B" : "Ctrl+B"})`} - className="text-ink-muted [-webkit-app-region:no-drag] hover:text-ink" - /> - {/* The workspaces, to jump straight into one; none is active on the start page. */} - <Switcher workspaces={workspaces} active={undefined} /> - <NewWorkspaceButton /> - <SettingsButton /> - </header> - ); -} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 536e8824..b20b3252 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,12 +1,26 @@ -import { createRootRoute, Outlet } from "@tanstack/react-router"; +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 { Palette } from "../shell/palette"; -export const Route = createRootRoute({ - component: () => ( - <> - <Outlet /> +// 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") }); + return ( + <SidebarProvider + className="h-full min-h-0" + style={{ "--sidebar-width": "17rem" } as CSSProperties} + widthStorageKey="codework.sidebar.width" + > + {!inSettings && <SessionsSidebar />} + <div className="flex min-w-0 flex-1 flex-col"> + <Outlet /> + </div> <Palette /> - </> - ), -}); + </SidebarProvider> + ); +} + +export const Route = createRootRoute({ component: Root }); diff --git a/apps/web/src/settings/layout.tsx b/apps/web/src/settings/layout.tsx index 605e321b..285894d6 100644 --- a/apps/web/src/settings/layout.tsx +++ b/apps/web/src/settings/layout.tsx @@ -1,5 +1,6 @@ +import { useAtomValue } from "@effect/atom-react"; import { Link, Outlet, useMatchRoute, useNavigate } from "@tanstack/react-router"; -import { Search } from "lucide-react"; +import { Search, X } from "lucide-react"; import { type CSSProperties, useState } from "react"; import { @@ -14,78 +15,101 @@ import { SidebarMenuItem, SidebarProvider, } from "@/components/ui/sidebar"; +import { chromeButton, insetTitleBar } from "../shell/titlebar"; +import { lastWorkspaceAtom, useWorkspaces, workspaceRoute } from "../wm/workspaces"; import { search } from "./sections"; -/** Settings: a flat section list on the window chrome beside one pane for the open section. */ +/** + * Settings fills the window: a flat section list on the window chrome beside one pane for the + * open section, under its own bar with the way out (also Esc). + */ export function SettingsLayout() { const matchRoute = useMatchRoute(); const navigate = useNavigate(); const [query, setQuery] = useState(""); const matches = search(query); const groups = [...new Set(matches.map((section) => section.group))]; + // Closing returns to the workspace that was on screen, like Esc does. + const lastId = useAtomValue(lastWorkspaceAtom); + const back = useWorkspaces().find((workspace) => workspace.id === lastId); return ( // Wider than shadcn's 16rem default, so section names never crowd the icons; // below md the sidebar folds to an icon rail so the section keeps the room. <SidebarProvider - className="h-full min-h-0 gap-2 p-2 pt-0" + className="h-full min-h-0 flex-col gap-0 p-2 pt-0" style={{ "--sidebar-width": "20rem" } as CSSProperties} > - <Sidebar collapsible="none" className="bg-transparent max-md:w-14"> - <SidebarHeader className="gap-3 px-2 pt-3 max-md:hidden"> - <h1 className="px-2 text-lg font-semibold">Settings</h1> - <div className="relative"> - <Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-ink-muted" /> - <SidebarInput - type="search" - placeholder="Search" - aria-label="Search settings" - value={query} - className="h-9 rounded-full border-transparent bg-ink/6 pl-9" - onChange={(event) => setQuery(event.target.value)} - onKeyDown={(event) => { - const first = matches[0]; - if (event.key === "Enter" && first !== undefined) void navigate({ to: first.to }); - if (event.key === "Escape" && query !== "") { - // Clears the query instead of leaving settings. - event.preventDefault(); - setQuery(""); - } - }} - /> - </div> - </SidebarHeader> - <SidebarContent> - {groups.map((group) => ( - <SidebarGroup key={group}> - <SidebarGroupLabel className="text-ink-muted max-md:sr-only">{group}</SidebarGroupLabel> - <SidebarMenu> - {matches - .filter((section) => section.group === group) - .map(({ to, label, icon: Icon }) => ( - <SidebarMenuItem key={to}> - <SidebarMenuButton - asChild - size="lg" - isActive={matchRoute({ to }) !== false} - className="h-9 text-[14px] max-md:justify-center" - > - <Link to={to} title={label}> - <Icon strokeWidth={1.75} /> - <span className="max-md:sr-only">{label}</span> - </Link> - </SidebarMenuButton> - </SidebarMenuItem> - ))} - </SidebarMenu> - </SidebarGroup> - ))} - {matches.length === 0 && <p className="px-4 py-2 text-ink-muted">No settings match “{query}”.</p>} - </SidebarContent> - </Sidebar> - <main className="panel min-w-0 flex-1 overflow-auto"> - <Outlet /> - </main> + <header + className={`flex h-12 shrink-0 items-center justify-end select-none [-webkit-app-region:drag] ${insetTitleBar ? "pl-18" : ""}`} + > + <button + type="button" + aria-label="Close settings" + title="Close settings (Esc)" + className={chromeButton} + onClick={() => void navigate(back === undefined ? { to: "/" } : workspaceRoute(back))} + > + <X className="size-4" strokeWidth={1.75} aria-hidden /> + </button> + </header> + <div className="flex min-h-0 flex-1 gap-2"> + <Sidebar collapsible="none" className="bg-transparent max-md:w-14"> + <SidebarHeader className="gap-3 px-2 pt-3 max-md:hidden"> + <h1 className="px-2 text-lg font-semibold">Settings</h1> + <div className="relative"> + <Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-ink-muted" /> + <SidebarInput + type="search" + placeholder="Search" + aria-label="Search settings" + value={query} + className="h-9 rounded-full border-transparent bg-ink/6 pl-9" + onChange={(event) => setQuery(event.target.value)} + onKeyDown={(event) => { + const first = matches[0]; + if (event.key === "Enter" && first !== undefined) void navigate({ to: first.to }); + if (event.key === "Escape" && query !== "") { + // Clears the query instead of leaving settings. + event.preventDefault(); + setQuery(""); + } + }} + /> + </div> + </SidebarHeader> + <SidebarContent> + {groups.map((group) => ( + <SidebarGroup key={group}> + <SidebarGroupLabel className="text-ink-muted max-md:sr-only">{group}</SidebarGroupLabel> + <SidebarMenu> + {matches + .filter((section) => section.group === group) + .map(({ to, label, icon: Icon }) => ( + <SidebarMenuItem key={to}> + <SidebarMenuButton + asChild + size="lg" + isActive={matchRoute({ to }) !== false} + className="h-9 text-[14px] max-md:justify-center" + > + <Link to={to} title={label}> + <Icon strokeWidth={1.75} /> + <span className="max-md:sr-only">{label}</span> + </Link> + </SidebarMenuButton> + </SidebarMenuItem> + ))} + </SidebarMenu> + </SidebarGroup> + ))} + {matches.length === 0 && <p className="px-4 py-2 text-ink-muted">No settings match “{query}”.</p>} + </SidebarContent> + </Sidebar> + <main className="panel min-w-0 flex-1 overflow-auto"> + <Outlet /> + </main> + </div> </SidebarProvider> ); } diff --git a/apps/web/src/shell/shell.tsx b/apps/web/src/shell/shell.tsx index 2b04863b..1005e26f 100644 --- a/apps/web/src/shell/shell.tsx +++ b/apps/web/src/shell/shell.tsx @@ -5,8 +5,8 @@ import { useEffect, useState } from "react"; import { Desktop } from "../wm/desktop"; import { lastWorkspaceAtom, useWorkspaces, workspaceRoute, workspacesAtom } from "../wm/workspaces"; -import { settingsShortcut, Switcher, workspaceShortcut } from "./switcher"; -import { BackButton, HomeButton, insetTitleBar, NewWorkspaceButton, SettingsButton } from "./titlebar"; +import { settingsShortcut, workspaceShortcut } from "./switcher"; +import { TitleBar } from "./titlebar"; /** * Lives in the pathless `_shell` layout so switching workspaces (a URL change) never remounts @@ -60,16 +60,9 @@ export function Shell() { }, [navigate, workspaces, inSettings, back]); return ( - <div className="flex h-full flex-col"> - <header - className={`flex h-12 shrink-0 items-center gap-1.5 px-2 select-none [-webkit-app-region:drag] ${insetTitleBar ? "pl-20" : ""}`} - > - <BackButton /> - <HomeButton /> - <Switcher workspaces={workspaces} active={inSettings ? undefined : shown} /> - <NewWorkspaceButton /> - <SettingsButton current={inSettings} /> - </header> + <div className="flex h-full min-w-0 flex-col"> + {/* Settings takes the whole window, with its own bar. */} + {!inSettings && <TitleBar active={shown} />} <div className="relative min-h-0 flex-1"> {shown !== undefined && ( // Hidden, not unmounted, so widgets keep their state behind settings. diff --git a/apps/web/src/shell/titlebar.tsx b/apps/web/src/shell/titlebar.tsx index 21681001..21862f34 100644 --- a/apps/web/src/shell/titlebar.tsx +++ b/apps/web/src/shell/titlebar.tsx @@ -1,11 +1,10 @@ import { Link, useCanGoBack, useNavigate, useRouter } from "@tanstack/react-router"; import { ChevronLeft, House, Plus, Settings } from "lucide-react"; -import { MAX_WORKSPACES, useCreateWorkspace, useWorkspaces, workspaceRoute } from "../wm/workspaces"; - -/* - * Title-bar controls shared by the workspace shell and the start page, so the two bars behave alike. - */ +import { SidebarTrigger, useSidebar } from "@/components/ui/sidebar"; +import { isMac } from "@codeworksh/widget/view"; +import { MAX_WORKSPACES, useCreateWorkspace, useWorkspaces, type Workspace, workspaceRoute } from "../wm/workspaces"; +import { Switcher } from "./switcher"; // The window has no native title bar on macOS; a title bar is its drag region and leaves room for the // inset traffic lights. @@ -15,7 +14,39 @@ export const insetTitleBar = window.desktopBridge?.platform === "darwin"; export const chromeButton = "grid size-7 shrink-0 place-items-center rounded-[10px] text-ink-muted press [-webkit-app-region:no-drag] hover:bg-ink/5 hover:text-ink disabled:opacity-40 disabled:hover:bg-transparent"; -export function BackButton() { +/** + * The title bar over the start page and workspaces: the window's drag region with the sidebar + * toggle, navigation, the workspace switcher and settings. With the sidebar hidden, or overlaid as + * a sheet in a narrow window, it sits in the window's corner, so it moves clear of the macOS + * traffic lights. + */ +export function TitleBar({ + active, +}: { + /** The workspace on screen; none on the start page. */ + readonly active: Workspace | undefined; +}) { + const { open, isMobile } = useSidebar(); + const inCorner = !open || isMobile; + const workspaces = useWorkspaces(); + return ( + <header + className={`flex h-12 shrink-0 items-center gap-1.5 px-2 select-none [-webkit-app-region:drag] ${inCorner && insetTitleBar ? "pl-20" : ""}`} + > + <SidebarTrigger + title={`Toggle sidebar (${isMac ? "⌘B" : "Ctrl+B"})`} + className="text-ink-muted [-webkit-app-region:no-drag] hover:text-ink" + /> + <BackButton /> + <HomeButton /> + <Switcher workspaces={workspaces} active={active} /> + <NewWorkspaceButton /> + <SettingsButton /> + </header> + ); +} + +function BackButton() { const router = useRouter(); const canGoBack = useCanGoBack(); return ( @@ -32,7 +63,7 @@ export function BackButton() { ); } -export function HomeButton() { +function HomeButton() { return ( <Link to="/" aria-label="Home" title="Home: the start page" className={chromeButton}> <House className="size-4" strokeWidth={1.75} aria-hidden /> @@ -41,7 +72,7 @@ export function HomeButton() { } /** Adds a workspace and goes to it; pushed to the bar's right edge. */ -export function NewWorkspaceButton() { +function NewWorkspaceButton() { const workspaces = useWorkspaces(); const navigate = useNavigate(); const create = useCreateWorkspace(); @@ -64,15 +95,9 @@ export function NewWorkspaceButton() { ); } -export function SettingsButton({ current = false }: { readonly current?: boolean }) { +function SettingsButton() { return ( - <Link - to="/settings/workspaces" - aria-label="Settings" - title="Settings" - aria-current={current ? "page" : undefined} - className={`${chromeButton} aria-[current=page]:bg-ink/8 aria-[current=page]:text-ink`} - > + <Link to="/settings/workspaces" aria-label="Settings" title="Settings" className={chromeButton}> <Settings className="size-4" strokeWidth={1.75} aria-hidden /> </Link> ); diff --git a/apps/web/src/wm/open.ts b/apps/web/src/wm/open.ts index 78d8314d..4757082a 100644 --- a/apps/web/src/wm/open.ts +++ b/apps/web/src/wm/open.ts @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useParams } from "@tanstack/react-router"; import type { OpenMode } from "@codeworksh/widget/view"; import { deskAtom } from "./instances"; @@ -14,17 +14,19 @@ import { } from "./workspaces"; /** - * Opens an address from outside the desktop (the start page), picking its workspace: + * Opens an address from outside a frame (the sidebar, the start page), picking its workspace: * 1. where it is already open (the frame looked at last), - * 2. else the last-used workspace if it shows nothing, - * 3. else a new auto workspace named `name` while fewer than nine exist, - * 4. else the last-used workspace. + * 2. else the workspace on screen, + * 3. else the last-used workspace if it shows nothing, + * 4. else a new auto workspace named `name` while fewer than nine exist, + * 5. else the last-used workspace. * The workspace then opens it (see the route's `open` search); `mode` "new" asks for a new frame. */ export function useOpenElsewhere() { const workspaces = useWorkspaces(); const { instances, order } = useAtomValue(deskAtom); const lastId = useAtomValue(lastWorkspaceAtom); + const { workspaceId: currentId } = useParams({ strict: false }); const create = useCreateWorkspace(); const navigate = useNavigate(); @@ -38,6 +40,9 @@ export function useOpenElsewhere() { const home = open && workspaces.find((workspace) => (open.tags & workspaceTag(workspace)) !== 0); if (home !== undefined) return go(home); + const current = workspaces.find((workspace) => workspace.id === currentId); + if (current !== undefined) return go(current); + const last = workspaces.find((workspace) => workspace.id === lastId) ?? workspaces[0]; if (last !== undefined && shows(last).length === 0) return go(last); From b10866389ba1e14942705a6cb47a099507907ba6 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 17 Oct 2026 17:56:56 +0530 Subject: [PATCH 10/20] fix(web): name a session frame before its conversation loads Swapping a session into a frame cleared the frame's chrome, so its header read the widget's default "Session" for a moment. The widget now takes its title and project from the sessions list the sidebar already holds, which also moves that list's atom beside the widget. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- apps/web/src/widgets/session/projects.ts | 18 ++++++++++++++++++ apps/web/src/widgets/session/view.tsx | 7 +++++-- 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/widgets/session/projects.ts diff --git a/apps/web/src/widgets/session/projects.ts b/apps/web/src/widgets/session/projects.ts new file mode 100644 index 00000000..b59814ae --- /dev/null +++ b/apps/web/src/widgets/session/projects.ts @@ -0,0 +1,18 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/reactivity"; + +import { Server } from "../../kernel/rpc"; + +/** 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); + +/** What the list already says about a session: its title and project name, before it is loaded. */ +export function useListedSession(id: string) { + const result = useAtomValue(projectsAtom); + if (!AsyncResult.isSuccess(result)) return undefined; + for (const project of result.value) { + const session = project.sessions.find((candidate) => candidate.id === id); + if (session !== undefined) return { title: session.title, project: project.name }; + } + return undefined; +} diff --git a/apps/web/src/widgets/session/view.tsx b/apps/web/src/widgets/session/view.tsx index 65a28128..36330ca8 100644 --- a/apps/web/src/widgets/session/view.tsx +++ b/apps/web/src/widgets/session/view.tsx @@ -10,6 +10,7 @@ import { ago } from "../time"; import { sessionId } from "./address"; import { SessionComposer } from "./composer"; import { Markdown } from "./markdown"; +import { useListedSession } from "./projects"; import { type Info, Json, topics, type Update } from "./schema"; const decodeReply = Schema.decodeUnknownOption(Json.Reply); @@ -168,8 +169,10 @@ function useConversation(id: string) { function Conversation({ id, instanceId }: { readonly id: string; readonly instanceId: string }) { const { setTitle, setIcon, setTab } = useFrame(); const { state, loadOlder } = useConversation(id); - const title = state.status === "ready" ? state.info.title : undefined; - const project = state.status === "ready" ? state.info.project?.name : undefined; + // Named from the sessions list at once, so the frame never shows its default title while loading. + const listed = useListedSession(id); + const title = state.status === "ready" ? state.info.title : listed?.title; + const project = state.status === "ready" ? state.info.project?.name : listed?.project; useEffect(() => { if (title === undefined) return; From ae20f4cad1202a2b2718d1ea29385cf7451fc6b3 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 17 Oct 2026 18:24:17 +0530 Subject: [PATCH 11/20] feat: persist open widgets across restarts The desk (every open widget, its workspace tags, pin, and the tile and focus order) now lives in a widget table on the app server, read with desk.get and replaced with desk.save. The web seeds its desk from the server, saves it debounced once edits settle, and holds off while a frame is dragged. Pins move onto the instance. Deleting a workspace clears its bit from every widget and drops widgets left on none, in the same transaction, so the client no longer does that. Migrations collapse into one init migration; existing dev databases are recreated. The hardcoded Projects widget is gone: a workspace starts empty and Projects opens from the command palette by its address. An E2E test boots the real server on a temp database and snapshots the round trip under __artifacts__. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- apps/server/package.json | 1 + apps/server/src/contract.ts | 38 +++++++ apps/server/src/db.ts | 29 +++-- apps/server/src/desk.ts | 103 ++++++++++++++++++ apps/server/src/handlers.ts | 3 + apps/server/src/workspaces.ts | 7 +- apps/server/test/__artifacts__/desk.json | 130 +++++++++++++++++++++++ apps/server/test/desk.test.ts | 102 ++++++++++++++++++ apps/server/tsconfig.json | 2 +- apps/server/vite.config.ts | 3 + apps/web/src/settings/workspaces.tsx | 10 +- apps/web/src/shell/palette.tsx | 39 ++++++- apps/web/src/shell/shell.tsx | 17 +-- apps/web/src/widgets/projects/index.ts | 4 + apps/web/src/wm/desktop.tsx | 26 ++--- apps/web/src/wm/instances.ts | 87 +++++++++++---- apps/web/src/wm/save.ts | 45 ++++++++ 17 files changed, 584 insertions(+), 62 deletions(-) create mode 100644 apps/server/src/desk.ts create mode 100644 apps/server/test/__artifacts__/desk.json create mode 100644 apps/server/test/desk.test.ts create mode 100644 apps/web/src/wm/save.ts diff --git a/apps/server/package.json b/apps/server/package.json index 37fd5070..caaa00a6 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -9,6 +9,7 @@ "scripts": { "build": "vp pack", "dev": "vp pack --watch", + "test": "vp test", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/apps/server/src/contract.ts b/apps/server/src/contract.ts index 6862a7c0..51fb0675 100644 --- a/apps/server/src/contract.ts +++ b/apps/server/src/contract.ts @@ -73,6 +73,35 @@ export class WorkspaceError extends Schema.TaggedError<WorkspaceError>()("Server message: Schema.String, }) {} +/** + * One placed widget on the desk. `tags` is its webwm mask: the workspaces it shows on, by their + * bits. `props` and `params` are the widget's own JSON; the server keeps them as given. + */ +export const Instance = Schema.Struct({ + id: Schema.String, + kind: Schema.String, + tags: Schema.Number, + props: Schema.Json, + pinned: Schema.Boolean, + title: Schema.optionalKey(Schema.String), + address: Schema.optionalKey(Schema.String), + params: Schema.optionalKey(Schema.Json), + openedBy: Schema.optionalKey(Schema.String), +}); +export type Instance = typeof Instance.Type; + +/** Every widget on every workspace, with the desk-wide tile and focus order over their ids. */ +export const Desk = Schema.Struct({ + instances: Schema.Array(Instance), + tileOrder: Schema.Array(Schema.String), + focusOrder: Schema.Array(Schema.String), +}); +export type Desk = typeof Desk.Type; + +export class DeskError extends Schema.TaggedError<DeskError>()("Server.DeskError", { + message: Schema.String, +}) {} + /** Package-scoped JSON storage: `package` namespaces keys so packages never collide. */ const KvKey = { package: Schema.String, key: Schema.String }; @@ -111,6 +140,15 @@ export const Api = RpcGroup.make( success: Schema.Void, error: WorkspaceError, }), + Rpc.make("desk.get", { + success: Desk, + }), + /** Replaces the whole desk; both orders must name every instance exactly once. */ + Rpc.make("desk.save", { + payload: Desk, + success: Schema.Void, + error: DeskError, + }), Rpc.make("kv.get", { payload: KvKey, success: Schema.OptionFromNullOr(Schema.Json), diff --git a/apps/server/src/db.ts b/apps/server/src/db.ts index bbd295a3..02185f6c 100644 --- a/apps/server/src/db.ts +++ b/apps/server/src/db.ts @@ -6,7 +6,7 @@ import { Migrator, SqlClient } from "effect/sql"; // "YYYYMMDD<counter>_<label>"; the migrator runs by high-water mark, so a new // id must sort after every applied one. const migrations = { - "202610090001_kv": Effect.gen(function* () { + "202610170001_init": Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql` CREATE TABLE kv ( @@ -17,9 +17,6 @@ const migrations = { PRIMARY KEY (package, key) ) `; - }), - "202610090002_workspaces": Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; yield* sql` CREATE TABLE workspaces ( id TEXT PRIMARY KEY, @@ -29,6 +26,7 @@ const migrations = { icon TEXT NOT NULL, layout TEXT NOT NULL, mfact REAL NOT NULL, + auto INTEGER NOT NULL DEFAULT 0, created INTEGER NOT NULL ) `; @@ -40,13 +38,6 @@ const migrations = { { id: "notes", name: "Notes", icon: "notebook-pen" }, ].map((workspace, index) => ({ ...workspace, bit: index, pos: index, layout: "tile", mfact: 0.6, created })), )}`; - }), - "202610160001_workspace_auto": Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`ALTER TABLE workspaces ADD COLUMN auto INTEGER NOT NULL DEFAULT 0`; - }), - "202610160002_event": Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; // AUTOINCREMENT so a seq is never reused, even after the newest rows are deleted. yield* sql` CREATE TABLE event ( @@ -61,6 +52,22 @@ const migrations = { `; yield* sql`CREATE INDEX event_topic_seq ON event (topic, seq)`; yield* sql`CREATE INDEX event_cause ON event (cause)`; + yield* sql` + CREATE TABLE widget ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + tags INTEGER NOT NULL, + tile_pos INTEGER NOT NULL, + focus_pos INTEGER NOT NULL, + pinned INTEGER NOT NULL DEFAULT 0, + props TEXT NOT NULL, + title TEXT, + address TEXT, + params TEXT, + opened_by TEXT, + created INTEGER NOT NULL + ) + `; }), }; diff --git a/apps/server/src/desk.ts b/apps/server/src/desk.ts new file mode 100644 index 00000000..4bfd765c --- /dev/null +++ b/apps/server/src/desk.ts @@ -0,0 +1,103 @@ +import { Clock, Effect, Schema } from "effect"; +import { SqlClient } from "effect/sql"; +import { Contract, type Instance } from "./contract.ts"; +import { Db } from "./db.ts"; + +// SQLite has no booleans; `pinned` is stored as 0 or 1. +const Rows = Schema.Array( + Schema.Struct({ + id: Schema.String, + kind: Schema.String, + tags: Schema.Number, + focusPos: Schema.Number, + pinned: Schema.Number, + props: Db.JsonText, + title: Schema.NullOr(Schema.String), + address: Schema.NullOr(Schema.String), + params: Schema.NullOr(Db.JsonText), + openedBy: Schema.NullOr(Schema.String), + }), +); +const decodeRows = Schema.decodeUnknownEffect(Rows); +const encodeJson = Schema.encodeEffect(Db.JsonText); + +/** Names every instance exactly once. */ +const covers = (order: readonly string[], ids: ReadonlySet<string>) => + order.length === ids.size && new Set(order).size === order.length && order.every((id) => ids.has(id)); + +/** Desk RPC handlers; the whole desk is read and replaced at once. */ +export const handlers = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + return { + "desk.get": Effect.fn("desk.get")(function* () { + const rows = yield* sql` + SELECT id, kind, tags, focus_pos, pinned, props, title, address, params, opened_by + FROM widget ORDER BY tile_pos + `.pipe(Effect.flatMap(decodeRows), Effect.orDie); + const instances = rows.map( + ({ id, kind, tags, props, pinned, title, address, params, openedBy }): Instance => ({ + id, + kind, + tags, + props, + pinned: pinned === 1, + ...(title === null ? {} : { title }), + ...(address === null ? {} : { address }), + ...(params === null ? {} : { params }), + ...(openedBy === null ? {} : { openedBy }), + }), + ); + const desk: Contract.Desk = { + instances, + tileOrder: rows.map((row) => row.id), + focusOrder: [...rows].sort((a, b) => a.focusPos - b.focusPos).map((row) => row.id), + }; + return desk; + }), + + "desk.save": Effect.fn("desk.save")(function* ({ instances, tileOrder, focusOrder }: Contract.Desk) { + const ids = new Set(instances.map((instance) => instance.id)); + if (ids.size !== instances.length) + return yield* new Contract.DeskError({ message: "Instance ids must be unique" }); + if (!covers(tileOrder, ids)) + return yield* new Contract.DeskError({ message: "The tile order must name every instance once" }); + if (!covers(focusOrder, ids)) + return yield* new Contract.DeskError({ message: "The focus order must name every instance once" }); + + const tilePos = new Map(tileOrder.map((id, pos) => [id, pos])); + const focusPos = new Map(focusOrder.map((id, pos) => [id, pos])); + const created = yield* Clock.currentTimeMillis; + const rows = yield* Effect.forEach( + instances, + Effect.fnUntraced(function* (instance) { + return { + id: instance.id, + kind: instance.kind, + tags: instance.tags, + tilePos: tilePos.get(instance.id) ?? 0, + focusPos: focusPos.get(instance.id) ?? 0, + pinned: instance.pinned ? 1 : 0, + props: yield* encodeJson(instance.props), + title: instance.title ?? null, + address: instance.address ?? null, + params: instance.params === undefined ? null : yield* encodeJson(instance.params), + openedBy: instance.openedBy ?? null, + created, + }; + }), + ).pipe(Effect.orDie); + + yield* sql + .withTransaction( + Effect.gen(function* () { + yield* sql`DELETE FROM widget`; + if (rows.length > 0) yield* sql`INSERT INTO widget ${sql.insert(rows)}`; + }), + ) + .pipe(Effect.orDie); + }), + }; +}); + +export * as Desk from "./desk.ts"; diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 6c517e56..4b146247 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -1,6 +1,7 @@ import { Effect } from "effect"; import { Codework } from "./codework.ts"; import { Contract, ModelsError, type Project } from "./contract.ts"; +import { Desk } from "./desk.ts"; import { Event } from "./event.ts"; import { Kv } from "./kv.ts"; import { Workspaces } from "./workspaces.ts"; @@ -35,11 +36,13 @@ const byProject = (sessions: ReadonlyArray<Codework.SessionInfo>): Project[] => export const layer = Contract.Api.toLayer( Effect.gen(function* () { const workspaces = yield* Workspaces.handlers; + const desk = yield* Desk.handlers; const events = yield* Event.Service; const kv = yield* Kv.Service; return Contract.Api.of({ ...workspaces, + ...desk, "projects.list": Effect.fn("projects.list")(function* () { const sessions = yield* Codework.call((client) => client("session.list", {})); diff --git a/apps/server/src/workspaces.ts b/apps/server/src/workspaces.ts index 9aee71b7..1b6a0ccc 100644 --- a/apps/server/src/workspaces.ts +++ b/apps/server/src/workspaces.ts @@ -101,14 +101,17 @@ export const handlers = Effect.gen(function* () { .pipe(Effect.catchTag("SqlError", Effect.die)); }), - // Widgets keep their masks here; the client clears the deleted bit from its instances. + // The deleted bit leaves every widget's mask with it; a widget left on no workspace goes too. "workspaces.delete": Effect.fn("workspaces.delete")(function* ({ id }: typeof WorkspaceId.Type) { yield* sql .withTransaction( Effect.gen(function* () { const current = yield* list; - if (!current.some((workspace) => workspace.id === id)) return yield* fail(`No workspace ${id}`); + const deleted = current.find((workspace) => workspace.id === id); + if (deleted === undefined) return yield* fail(`No workspace ${id}`); yield* sql`DELETE FROM workspaces WHERE id = ${id}`; + yield* sql`UPDATE widget SET tags = tags & ~${1 << deleted.bit}`; + yield* sql`DELETE FROM widget WHERE tags = 0`; yield* renumber(current.filter((workspace) => workspace.id !== id).map((workspace) => workspace.id)); }), ) diff --git a/apps/server/test/__artifacts__/desk.json b/apps/server/test/__artifacts__/desk.json new file mode 100644 index 00000000..b9a68281 --- /dev/null +++ b/apps/server/test/__artifacts__/desk.json @@ -0,0 +1,130 @@ +{ + "empty": { + "instances": [], + "tileOrder": [], + "focusOrder": [] + }, + "workspace": { + "id": "<workspace>", + "bit": 3, + "pos": 3, + "name": "Scratch", + "icon": "box", + "layout": "tile", + "mfact": 0.6, + "auto": false + }, + "saved": { + "instances": [ + { + "id": "browser-1", + "kind": "browser", + "tags": 9, + "props": {}, + "pinned": false, + "title": "Docs", + "address": "https://example.com", + "params": { + "zoom": 1.25, + "history": [ + "a", + "b" + ] + }, + "openedBy": "notes-1" + }, + { + "id": "notes-1", + "kind": "notes", + "tags": 8, + "props": { + "text": "hello" + }, + "pinned": true + } + ], + "tileOrder": [ + "browser-1", + "notes-1" + ], + "focusOrder": [ + "notes-1", + "browser-1" + ] + }, + "roundTrip": { + "instances": [ + { + "id": "browser-1", + "kind": "browser", + "tags": 9, + "props": {}, + "pinned": false, + "title": "Docs", + "address": "https://example.com", + "params": { + "zoom": 1.25, + "history": [ + "a", + "b" + ] + }, + "openedBy": "notes-1" + }, + { + "id": "notes-1", + "kind": "notes", + "tags": 8, + "props": { + "text": "hello" + }, + "pinned": true + } + ], + "tileOrder": [ + "browser-1", + "notes-1" + ], + "focusOrder": [ + "notes-1", + "browser-1" + ] + }, + "afterDelete": { + "instances": [ + { + "id": "browser-1", + "kind": "browser", + "tags": 1, + "props": {}, + "pinned": false, + "title": "Docs", + "address": "https://example.com", + "params": { + "zoom": 1.25, + "history": [ + "a", + "b" + ] + }, + "openedBy": "notes-1" + } + ], + "tileOrder": [ + "browser-1" + ], + "focusOrder": [ + "browser-1" + ] + }, + "errors": [ + { + "tag": "Server.DeskError", + "message": "The tile order must name every instance once" + }, + { + "tag": "Server.DeskError", + "message": "The focus order must name every instance once" + } + ] +} diff --git a/apps/server/test/desk.test.ts b/apps/server/test/desk.test.ts new file mode 100644 index 00000000..fe33b2b2 --- /dev/null +++ b/apps/server/test/desk.test.ts @@ -0,0 +1,102 @@ +/* @effect-diagnostics nodeBuiltinImport:off -- the suite spawns the real server in a temp dir. */ +import { Effect, Layer } from "effect"; +import { RpcClient, RpcSerialization } from "effect/rpc"; +import { Socket } from "effect/socket"; +import { type ChildProcess, spawn } from "node:child_process"; +import { mkdtempSync, rmSync } 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 } from "../src/contract.ts"; + +const entry = fileURLToPath(new URL("../src/index.ts", import.meta.url)); +const home = mkdtempSync(join(tmpdir(), "codework-desk-")); +let server: ChildProcess; +let port: number; + +beforeAll(async () => { + // CODEWORK_HOME_DIR keeps the server from finding a real CodeWork service; desk and workspace + // RPCs never need one. + server = spawn(process.execPath, [entry], { + env: { ...process.env, CODEWORK_DATABASE: join(home, "app.db"), CODEWORK_HOME_DIR: home }, + stdio: ["ignore", "pipe", "inherit"], + }); + port = await new Promise<number>((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)); + // The server does not yet stop on SIGTERM while no CodeWork service is registered. + server.kill("SIGKILL"); + await exited; + rmSync(home, { recursive: true, force: true }); +}); + +// Provided around the whole scenario: the socket lives as long as the layer does. +const protocol = () => + RpcClient.layerProtocolSocket().pipe( + Layer.provide(Socket.layerWebSocket(`ws://127.0.0.1:${port}/rpc`)), + Layer.provide(Socket.layerWebSocketConstructorGlobal), + Layer.provide(RpcSerialization.layerNdjson), + ); + +it("desk round-trips and follows workspace deletion", async () => { + // Instances are listed in tile order, as desk.get returns them. + const steps = await Effect.gen(function* () { + const rpc = yield* RpcClient.make(Contract.Api); + const empty = yield* rpc["desk.get"](); + + const workspace = yield* rpc["workspaces.create"]({ name: "Scratch", icon: "box" }); + const bit = 1 << workspace.bit; + const saved: Contract.Desk = { + instances: [ + { + id: "browser-1", + kind: "browser", + tags: bit | 1, + props: {}, + pinned: false, + title: "Docs", + address: "https://example.com", + params: { zoom: 1.25, history: ["a", "b"] }, + openedBy: "notes-1", + }, + { id: "notes-1", kind: "notes", tags: bit, props: { text: "hello" }, pinned: true }, + ], + tileOrder: ["browser-1", "notes-1"], + focusOrder: ["notes-1", "browser-1"], + }; + yield* rpc["desk.save"](saved); + const roundTrip = yield* rpc["desk.get"](); + + yield* rpc["workspaces.delete"]({ id: workspace.id }); + const afterDelete = yield* rpc["desk.get"](); + + const badOrder = yield* rpc["desk.save"]({ ...afterDelete, tileOrder: [] }).pipe(Effect.flip); + const duplicate = yield* rpc["desk.save"]({ + ...afterDelete, + focusOrder: [...afterDelete.focusOrder, ...afterDelete.focusOrder], + }).pipe(Effect.flip); + + return { + empty, + workspace: { ...workspace, id: "<workspace>" }, + saved, + roundTrip, + afterDelete, + errors: [badOrder, duplicate].map((error) => ({ tag: error._tag, message: error.message })), + }; + }).pipe(Effect.scoped, Effect.provide(protocol()), Effect.runPromise); + + expect(steps.roundTrip).toEqual(steps.saved); + expect(steps.afterDelete.instances).toEqual([{ ...steps.saved.instances[0], tags: 1 }]); + await expect(`${JSON.stringify(steps, null, "\t")}\n`).toMatchFileSnapshot("./__artifacts__/desk.json"); +}, 20_000); diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 4d743372..f3034707 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "tsBuildInfoFile": "../../.cache/typescript/server.tsbuildinfo" }, - "include": ["src", "vite.config.ts"] + "include": ["src", "test", "vite.config.ts"] } diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 3e8a3239..202c8ec8 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -13,4 +13,7 @@ export default defineConfig({ clean: true, }, ], + test: { + include: ["test/**/*.test.ts"], + }, }); diff --git a/apps/web/src/settings/workspaces.tsx b/apps/web/src/settings/workspaces.tsx index e25374ef..7bf30756 100644 --- a/apps/web/src/settings/workspaces.tsx +++ b/apps/web/src/settings/workspaces.tsx @@ -1,4 +1,4 @@ -import { useAtom, useAtomSet } from "@effect/atom-react"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { GripVertical, Plus, Trash2 } from "lucide-react"; import { useState } from "react"; @@ -19,7 +19,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { isMac } from "@codeworksh/widget/view"; import { iconOf, icons } from "../shell/icons"; import { shortcutLabel } from "../shell/switcher"; -import { deskAtom, only, untag } from "../wm/instances"; +import { deskAtom, deskKey, only } from "../wm/instances"; import { deleteWorkspace, reorderWorkspaces, @@ -229,7 +229,7 @@ function IconPicker({ workspace, onError }: RowProps) { /** Deletes after a confirm that says which widgets close with it. */ function DeleteButton({ workspace, onError }: RowProps) { const remove = useAtomSet(deleteWorkspace, { mode: "promise" }); - const [desk, setDesk] = useAtom(deskAtom); + const desk = useAtomValue(deskAtom); const tag = workspaceTag(workspace); const closing = only(desk, tag).length; return ( @@ -260,8 +260,8 @@ function DeleteButton({ workspace, onError }: RowProps) { <AlertDialogAction variant="destructive" onClick={() => - remove({ payload: { id: workspace.id }, reactivityKeys: workspacesKey }).then( - () => setDesk((current) => untag(current, tag)), + // The server drops the workspace's widgets, so the desk reloads with the list. + remove({ payload: { id: workspace.id }, reactivityKeys: [...workspacesKey, ...deskKey] }).catch( (cause: unknown) => onError(describe(cause)), ) } diff --git a/apps/web/src/shell/palette.tsx b/apps/web/src/shell/palette.tsx index 03a12cbf..ae91f89a 100644 --- a/apps/web/src/shell/palette.tsx +++ b/apps/web/src/shell/palette.tsx @@ -1,6 +1,6 @@ -import { useAtom } from "@effect/atom-react"; -import { useNavigate } from "@tanstack/react-router"; -import { Check, ChevronRight, Settings } from "lucide-react"; +import { useAtom, useAtomValue } from "@effect/atom-react"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { Check, ChevronRight, FolderKanban, Settings } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { @@ -12,12 +12,16 @@ import { CommandList, } from "@/components/ui/command"; import { isTheme, previewTheme, THEMES, themeAtom } from "../theme"; +import { projects, projectsAddress } from "../widgets/projects"; +import { deskAtom } from "../wm/instances"; +import { lastWorkspaceAtom, openInWorkspaceRoute, useWorkspaces, workspaceTag } from "../wm/workspaces"; import { paletteShortcut } from "./switcher"; import { Swatch } from "./swatch"; /** How long the highlight rests on a theme before it previews, so scrolling through doesn't strobe. */ const PREVIEW_DELAY = 200; +const OPEN_PROJECTS = "widgets:projects"; const THEME_SETTINGS = "settings:themes"; /** @@ -30,13 +34,27 @@ export function Palette() { const [open, setOpen] = useState(false); const [saved, setTheme] = useAtom(themeAtom); // The highlighted item, controlled so each opening starts at the top. - const [value, setValue] = useState(THEME_SETTINGS); + const [value, setValue] = useState(OPEN_PROJECTS); const pending = useRef<ReturnType<typeof setTimeout>>(undefined); + const workspaces = useWorkspaces(); + const { instances } = useAtomValue(deskAtom); + const lastId = useAtomValue(lastWorkspaceAtom); + const { workspaceId } = useParams({ strict: false }); + + // Where Projects already is, so the desktop reveals it there; else the workspace on screen, else the last used. + const openProjects = () => { + const open = instances.find((instance) => instance.kind === projects.kind); + const target = + (open && workspaces.find((workspace) => (open.tags & workspaceTag(workspace)) !== 0)) ?? + workspaces.find((workspace) => workspace.id === (workspaceId ?? lastId)) ?? + workspaces[0]; + if (target !== undefined) void navigate(openInWorkspaceRoute(target, projectsAddress, "replace")); + }; const show = (next: boolean) => { clearTimeout(pending.current); if (next) { - setValue(THEME_SETTINGS); + setValue(OPEN_PROJECTS); } else { previewTheme(saved); } @@ -81,6 +99,17 @@ export function Palette() { <CommandInput placeholder="Type a command or search…" /> <CommandList className="max-h-[min(420px,60vh)]"> <CommandEmpty>No results.</CommandEmpty> + <CommandGroup heading="Widgets"> + <CommandItem + value={OPEN_PROJECTS} + keywords={["open projects", "sessions"]} + onSelect={() => run(openProjects)} + className="hover:bg-accent/50" + > + <FolderKanban strokeWidth={1.75} /> + Open Projects + </CommandItem> + </CommandGroup> <CommandGroup heading="Settings"> <CommandItem value={THEME_SETTINGS} diff --git a/apps/web/src/shell/shell.tsx b/apps/web/src/shell/shell.tsx index 1005e26f..22d64073 100644 --- a/apps/web/src/shell/shell.tsx +++ b/apps/web/src/shell/shell.tsx @@ -4,6 +4,7 @@ import { AsyncResult } from "effect/reactivity"; import { useEffect, useState } from "react"; import { Desktop } from "../wm/desktop"; +import { deskQueryAtom } from "../wm/instances"; import { lastWorkspaceAtom, useWorkspaces, workspaceRoute, workspacesAtom } from "../wm/workspaces"; import { settingsShortcut, workspaceShortcut } from "./switcher"; import { TitleBar } from "./titlebar"; @@ -31,6 +32,9 @@ export function Shell() { // the list has settled: a first load, or the refetch after creating one, must not bounce the user out. const listed = useAtomValue(workspacesAtom); const loaded = AsyncResult.isSuccess(listed) && !listed.waiting; + // The desktop edits and saves the whole desk, so it waits for the saved one rather than start empty. + const desk = useAtomValue(deskQueryAtom); + const ready = AsyncResult.isSuccess(listed) && AsyncResult.isSuccess(desk); useEffect(() => { if (loaded && workspaces.length === 0 && !inSettings) void navigate({ to: "/", replace: true }); }, [loaded, workspaces.length, inSettings, navigate]); @@ -64,12 +68,13 @@ export function Shell() { {/* Settings takes the whole window, with its own bar. */} {!inSettings && <TitleBar active={shown} />} <div className="relative min-h-0 flex-1"> - {shown !== undefined && ( - // Hidden, not unmounted, so widgets keep their state behind settings. - <div className={`h-full ${inSettings ? "invisible" : ""}`} inert={inSettings}> - <Desktop workspace={shown} /> - </div> - )} + {ready && + shown !== undefined && ( + // Hidden, not unmounted, so widgets keep their state behind settings. + <div className={`h-full ${inSettings ? "invisible" : ""}`} inert={inSettings}> + <Desktop workspace={shown} /> + </div> + )} {inSettings && ( <div className="absolute inset-0"> <Outlet /> diff --git a/apps/web/src/widgets/projects/index.ts b/apps/web/src/widgets/projects/index.ts index 96e222e7..8286a83a 100644 --- a/apps/web/src/widgets/projects/index.ts +++ b/apps/web/src/widgets/projects/index.ts @@ -4,6 +4,9 @@ import { Schema } from "effect"; import { defineWidget } from "@codeworksh/widget/view"; import { Projects } from "./projects"; +/** What opens the Projects widget, from the command palette. */ +export const projectsAddress = "codework://projects"; + /** Every project with its sessions; picking a session opens it beside the list. */ export const projects = defineWidget({ kind: "codework:projects", @@ -13,6 +16,7 @@ export const projects = defineWidget({ minHeight: 200, cardinality: "one", priority: "builtin", + opens: [projectsAddress], props: Schema.Struct({}), component: Projects, }); diff --git a/apps/web/src/wm/desktop.tsx b/apps/web/src/wm/desktop.tsx index 9a95ab7f..6b0dd8a1 100644 --- a/apps/web/src/wm/desktop.tsx +++ b/apps/web/src/wm/desktop.tsx @@ -25,6 +25,7 @@ import { Frame, FRAME_HEADER_HEIGHT } from "./frame"; import { Overflow } from "./overflow"; import { add, deskAtom, type Instance, remove, retarget, focus as raise, update } from "./instances"; import { resolve } from "./resolver"; +import { useSaveDesk } from "./save"; import { deleteWorkspace, MAX_WORKSPACES, @@ -122,8 +123,8 @@ export function Desktop({ workspace }: DesktopProps) { // Local edits ahead of the database, so dragging the divider never waits on a round trip. const [views, setViews] = useState<ReadonlyMap<string, View>>(() => new Map()); const [chrome, setChrome] = useAtom(chromeAtom); - const [pinned, setPinned] = useState<ReadonlySet<WidgetId>>(() => new Set()); const [drag, setDrag] = useState<Drag | null>(null); + useSaveDesk(drag !== null); const [resizing, setResizing] = useState(false); const [ref, size] = useElementSize<HTMLDivElement>(); // A window resize must not tween: frames would trail the window edge by the transition's length. @@ -296,7 +297,11 @@ export function Desktop({ workspace }: DesktopProps) { const widget = resolve(builtins, address, kind); if (widget === undefined) throw new Error(`No widget opens ${address}`); if (mode === "replace") { - const existing = recent.find((instance) => instance.address === address && instance.kind === widget.kind); + // A one-per-desk widget is the same frame whatever address it was opened by. + const existing = recent.find( + (instance) => + instance.kind === widget.kind && (widget.cardinality === "one" || instance.address === address), + ); if (existing !== undefined) { if (params !== undefined) setDesk((current) => update(current, existing.id, { params })); reveal(existing); @@ -308,7 +313,7 @@ export function Desktop({ workspace }: DesktopProps) { instance.openedBy === caller && instance.kind === widget.kind && (instance.tags & activeTags) !== 0 && - !pinned.has(instance.id), + !instance.pinned, ) : undefined; if (target !== undefined) { @@ -322,6 +327,7 @@ export function Desktop({ workspace }: DesktopProps) { kind: widget.kind, tags: activeTags, props: {}, + pinned: false, address, openedBy: caller, ...(params === undefined ? {} : { params }), @@ -362,12 +368,8 @@ export function Desktop({ workspace }: DesktopProps) { setLayout("monocle"); }; - const togglePin = (widgetId: WidgetId) => - setPinned((current) => { - const next = new Set(current); - if (!next.delete(widgetId)) next.add(widgetId); - return next; - }); + const togglePin = (instance: Instance) => + setDesk((current) => update(current, instance.id, { pinned: !instance.pinned })); const mainClass = "box-border h-full overflow-auto p-1 [scrollbar-gutter:stable_both-edges]"; // The first measurement is 0×0; frames committed at it would tween from the origin into their slots. @@ -404,13 +406,13 @@ export function Desktop({ workspace }: DesktopProps) { collapsed={collapsed === true} hidden={hidden} focused={widget.id === focusedId} - pinned={pinned.has(widget.id)} + pinned={instance.pinned} maximized={layout === "monocle"} - canDrag={layout === "tile" && !pinned.has(widget.id)} + canDrag={layout === "tile" && !instance.pinned} offset={drag?.widgetId === widget.id ? drag : null} animate={!resizing && !resized} onFocus={() => focusWidget(widget.id)} - onPin={() => togglePin(widget.id)} + onPin={() => togglePin(instance)} onMaximize={() => toggleMax(widget.id)} destinations={destinations} onMove={(workspaceId) => { diff --git a/apps/web/src/wm/instances.ts b/apps/web/src/wm/instances.ts index 49b0b4e9..419cfd3e 100644 --- a/apps/web/src/wm/instances.ts +++ b/apps/web/src/wm/instances.ts @@ -1,7 +1,10 @@ -import { Atom } from "effect/reactivity"; -import { attachWidget, detachWidget, focusWidget, moveWidgetRelative, tagBit, type WidgetOrder } from "webwm"; +import type { Desk as SavedDesk } from "@codeworksh/server/contract"; +import type { Schema } from "effect"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { attachWidget, detachWidget, focusWidget, moveWidgetRelative, type WidgetOrder } from "webwm"; import type { Params } from "@codeworksh/widget/view"; +import { Server } from "../kernel/rpc"; /** * One placed copy of a widget. The window manager owns instances; a widget only @@ -15,6 +18,8 @@ export interface Instance { readonly tags: number; /** Saved settings, decoded by the widget's own schema before it renders. */ readonly props: unknown; + /** A pinned frame stays put: it can't be dragged, and opens never replace what it shows. */ + readonly pinned: boolean; /** Overrides the widget's default title. */ readonly title?: string; /** What the instance shows when opened by address; opening it again focuses this instance. */ @@ -30,18 +35,69 @@ export interface Desk { readonly order: WidgetOrder; } -// The Code workspace's tag bit, as the database is seeded. -const code = tagBit(1); +/** The workspaces delete names this key too: the server drops the deleted workspace's widgets. */ +export const deskKey = ["desk"]; -// Hardcoded until instances live in SQLite. -const seed: readonly Instance[] = [{ id: "projects", kind: "codework:projects", tags: code, props: {} }]; +export const deskQueryAtom = Server.query("desk.get", undefined, { reactivityKeys: deskKey }); -const ids = seed.map(({ id }) => id); +// Kept alive so a save started as the desktop unmounts still lands. +export const saveDesk = Server.mutation("desk.save").pipe(Atom.keepAlive); -/** Shared so settings can close the widgets of a deleted workspace while the desktop is hidden. */ -export const deskAtom = Atom.make<Desk>({ instances: seed, order: { tileOrder: ids, focusOrder: ids } }).pipe( - Atom.keepAlive, -); +const empty: Desk = { instances: [], order: { tileOrder: [], focusOrder: [] } }; + +const isParams = (json: Schema.Json | undefined): json is Schema.JsonObject => + typeof json === "object" && json !== null && !Array.isArray(json); + +// Memoized so the same server desk always maps to the same object, which saving compares against. +const loaded = new WeakMap<SavedDesk, Desk>(); + +function fromSaved(saved: SavedDesk): Desk { + const cached = loaded.get(saved); + if (cached !== undefined) return cached; + const desk: Desk = { + instances: saved.instances.map(({ params, ...instance }) => ({ + ...instance, + ...(isParams(params) ? { params } : {}), + })), + order: { tileOrder: saved.tileOrder, focusOrder: saved.focusOrder }, + }; + loaded.set(saved, desk); + return desk; +} + +// Widgets hand the shell JSON-shaped props and params; the server checks them as JSON. +export const toSaved = ({ instances, order }: Desk): SavedDesk => ({ + instances: instances.map(({ props, params, ...instance }) => ({ + ...instance, + props: props as Schema.Json, + ...(params === undefined ? {} : { params: params as Schema.Json }), + })), + tileOrder: order.tileOrder, + focusOrder: order.focusOrder, +}); + +/** The desk as the server last sent it; undefined until it loads. */ +export const loadedDeskAtom = Atom.make((get) => { + const result = get(deskQueryAtom); + return AsyncResult.isSuccess(result) ? fromSaved(result.value) : undefined; +}); + +// Local edits, tied to the server desk they were made on: a refetch (a workspace deleted) replaces them. +const editsAtom = Atom.make<{ readonly base: Desk; readonly desk: Desk } | undefined>(undefined).pipe(Atom.keepAlive); + +/** The desk the UI edits, empty until it loads; the client owns it from then on and saves it back. */ +export const deskAtom = Atom.writable<Desk, Desk>( + (get) => { + const base = get(loadedDeskAtom); + const edits = get(editsAtom); + if (base === undefined) return empty; + return edits?.base === base ? edits.desk : base; + }, + (ctx, desk) => { + const base = ctx.get(loadedDeskAtom); + if (base !== undefined) ctx.set(editsAtom, { base, desk }); + }, +).pipe(Atom.keepAlive); /** Adds a focused instance to the tile order right after `after`, or last. */ export function add(desk: Desk, instance: Instance, after?: string): Desk { @@ -61,15 +117,6 @@ export const remove = (desk: Desk, id: string): Desk => ({ /** Instances that appear only on the workspace with this tag mask. */ export const only = (desk: Desk, tag: number) => desk.instances.filter((instance) => instance.tags === tag); -/** Takes a deleted workspace's tag off every instance, closing those it leaves on no workspace. */ -export const untag = (desk: Desk, tag: number): Desk => - only(desk, tag).reduce<Desk>((current, instance) => remove(current, instance.id), { - ...desk, - instances: desk.instances.map((instance) => - instance.tags & tag && instance.tags !== tag ? { ...instance, tags: instance.tags & ~tag } : instance, - ), - }); - export const focus = (desk: Desk, id: string): Desk => ({ ...desk, order: focusWidget(desk.order, id) }); /** Shows another address in an instance; the old address's params go with it. */ diff --git a/apps/web/src/wm/save.ts b/apps/web/src/wm/save.ts new file mode 100644 index 00000000..e7bf8a5c --- /dev/null +++ b/apps/web/src/wm/save.ts @@ -0,0 +1,45 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { useCallback, useEffect, useRef } from "react"; + +import { type Desk, deskAtom, loadedDeskAtom, saveDesk, toSaved } from "./instances"; + +const DELAY = 300; + +/** + * Saves the desk once edits settle, holding off while `paused` (a frame mid-drag). Leaving the page + * or unmounting saves at once. The desk as loaded, or as last saved, is never sent again. + */ +export function useSaveDesk(paused: boolean) { + const desk = useAtomValue(deskAtom); + const loaded = useAtomValue(loadedDeskAtom); + const save = useAtomSet(saveDesk); + const latest = useRef({ desk, loaded }); + const sent = useRef<Desk>(undefined); + + useEffect(() => { + latest.current = { desk, loaded }; + }); + + const flush = useCallback(() => { + const { desk, loaded } = latest.current; + if (loaded === undefined || desk === loaded || desk === sent.current) return; + sent.current = desk; + save({ payload: toSaved(desk) }); + }, [save]); + + useEffect(() => { + if (paused) return; + const timer = setTimeout(flush, DELAY); + return () => clearTimeout(timer); + }, [desk, paused, flush]); + + useEffect(() => { + window.addEventListener("pagehide", flush); + window.addEventListener("beforeunload", flush); + return () => { + window.removeEventListener("pagehide", flush); + window.removeEventListener("beforeunload", flush); + flush(); + }; + }, [flush]); +} From 448f851778d3183f2b43980fca0015353f72fa23 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 17 Oct 2026 18:56:38 +0530 Subject: [PATCH 12/20] feat(web): shift-click opens a session in a new workspace Like a browser's new window beside Cmd-click's new tab: a Shift-click on a sidebar session skips the open and current workspaces and creates an auto workspace named after the project. The row's tooltip says so. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- apps/web/src/landing/sidebar.tsx | 9 ++++++--- apps/web/src/wm/open.ts | 29 ++++++++++++++++++----------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/apps/web/src/landing/sidebar.tsx b/apps/web/src/landing/sidebar.tsx index a30e05a4..4e0bd334 100644 --- a/apps/web/src/landing/sidebar.tsx +++ b/apps/web/src/landing/sidebar.tsx @@ -36,9 +36,12 @@ export function SessionsSidebar() { const current = instances.find((instance) => instance.id === focusedId)?.address; // Picking lands on the start page, where the folder is used. const addProject = pickFolder === undefined ? undefined : () => void pickFolder().then(() => navigate({ to: "/" })); - // Primary and middle clicks open; a right click is left to the context menu. + // Primary and middle clicks open; a right click is left to the context menu. Shift, like a + // browser's new window, asks for a workspace of its own. const open = (session: { id: string; project: { name: string } }, event: MouseEvent) => { - if (event.button <= 1) void openElsewhere(sessionAddress(session.id), session.project.name, openMode(event)); + if (event.button > 1) return; + const placement = event.shiftKey ? "workspace" : openMode(event); + void openElsewhere(sessionAddress(session.id), session.project.name, placement); }; const needle = query.trim().toLowerCase(); @@ -109,7 +112,7 @@ export function SessionsSidebar() { <li key={session.id}> <button type="button" - title={`${session.project.path}\n${isMac ? "⌘" : "Ctrl"}-click opens it in a new frame`} + title={`${session.project.path}\n${isMac ? "⌘" : "Ctrl"}-click opens it in a new frame\n⇧-click opens it in a new workspace`} aria-current={sessionAddress(session.id) === current ? "true" : undefined} className="block w-full rounded-[8px] px-2.5 py-2 text-left transition-colors outline-none hover:bg-ink/5 focus-visible:bg-ink/5 aria-[current=true]:bg-ink/8" onClick={(event) => open(session, event)} diff --git a/apps/web/src/wm/open.ts b/apps/web/src/wm/open.ts index 4757082a..6c8a7c72 100644 --- a/apps/web/src/wm/open.ts +++ b/apps/web/src/wm/open.ts @@ -13,14 +13,18 @@ import { workspaceTag, } from "./workspaces"; +/** Where to open: in a frame (`OpenMode`), or in a workspace of its own. */ +export type Placement = OpenMode | "workspace"; + /** * Opens an address from outside a frame (the sidebar, the start page), picking its workspace: - * 1. where it is already open (the frame looked at last), + * 1. where it is already open (the frame looked at last), unless a new workspace is asked for, * 2. else the workspace on screen, * 3. else the last-used workspace if it shows nothing, * 4. else a new auto workspace named `name` while fewer than nine exist, * 5. else the last-used workspace. - * The workspace then opens it (see the route's `open` search); `mode` "new" asks for a new frame. + * A "workspace" placement skips to 4. The workspace then opens it (see the route's `open` + * search); `mode` "new" asks for a new frame. */ export function useOpenElsewhere() { const workspaces = useWorkspaces(); @@ -30,21 +34,24 @@ export function useOpenElsewhere() { const create = useCreateWorkspace(); const navigate = useNavigate(); - return async (address: string, name: string, mode: OpenMode) => { + return async (address: string, name: string, placement: Placement) => { + const mode: OpenMode = placement === "workspace" ? "new" : placement; const go = (workspace: Workspace) => navigate(openInWorkspaceRoute(workspace, address, mode)); const shows = (workspace: Workspace) => instances.filter((instance) => (instance.tags & workspaceTag(workspace)) !== 0); + const last = workspaces.find((workspace) => workspace.id === lastId) ?? workspaces[0]; - const recent = order.focusOrder.flatMap((id) => instances.find((instance) => instance.id === id) ?? []); - const open = recent.find((instance) => instance.address === address); - const home = open && workspaces.find((workspace) => (open.tags & workspaceTag(workspace)) !== 0); - if (home !== undefined) return go(home); + if (placement !== "workspace") { + const recent = order.focusOrder.flatMap((id) => instances.find((instance) => instance.id === id) ?? []); + const open = recent.find((instance) => instance.address === address); + const home = open && workspaces.find((workspace) => (open.tags & workspaceTag(workspace)) !== 0); + if (home !== undefined) return go(home); - const current = workspaces.find((workspace) => workspace.id === currentId); - if (current !== undefined) return go(current); + const current = workspaces.find((workspace) => workspace.id === currentId); + if (current !== undefined) return go(current); - const last = workspaces.find((workspace) => workspace.id === lastId) ?? workspaces[0]; - if (last !== undefined && shows(last).length === 0) return go(last); + if (last !== undefined && shows(last).length === 0) return go(last); + } if (workspaces.length < MAX_WORKSPACES) { const created = await create({ name, icon: "messages-square", auto: true }).catch(() => undefined); From 33db91780a099f8560137905693cb172ef26befa Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 17 Oct 2026 18:56:38 +0530 Subject: [PATCH 13/20] fix(server): sweep only empty auto workspaces at startup With the desk persisted, an auto workspace's frames outlive the app, so the startup purge of every auto workspace is wrong: it left their widgets with stale bits. Startup now deletes only auto workspaces with no widget on their bit, which the E2E test covers with a restart. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- apps/server/src/workspaces.ts | 9 +++-- apps/server/test/__artifacts__/desk.json | 43 +++++++++++++++++++++++- apps/server/test/desk.test.ts | 43 ++++++++++++++++++++---- 3 files changed, 84 insertions(+), 11 deletions(-) diff --git a/apps/server/src/workspaces.ts b/apps/server/src/workspaces.ts index 1b6a0ccc..7dfa48b9 100644 --- a/apps/server/src/workspaces.ts +++ b/apps/server/src/workspaces.ts @@ -30,12 +30,15 @@ export const handlers = Effect.gen(function* () { const renumber = (ids: readonly string[]) => Effect.forEach(ids, (id, pos) => sql`UPDATE workspaces SET pos = ${pos} WHERE id = ${id}`, { discard: true }); - // The frames an auto workspace held do not outlive the app, so neither does it. Having none at - // all is fine: the start page needs no workspace. + // An auto workspace exists to show its frames; one that lost them without being left (a save that + // never landed) goes at startup, the only time no client is editing. yield* sql .withTransaction( Effect.gen(function* () { - yield* sql`DELETE FROM workspaces WHERE auto = 1`; + yield* sql` + DELETE FROM workspaces WHERE auto = 1 + AND NOT EXISTS (SELECT 1 FROM widget WHERE (widget.tags & (1 << workspaces.bit)) != 0) + `; yield* renumber((yield* list).map((workspace) => workspace.id)); }), ) diff --git a/apps/server/test/__artifacts__/desk.json b/apps/server/test/__artifacts__/desk.json index b9a68281..460db1fc 100644 --- a/apps/server/test/__artifacts__/desk.json +++ b/apps/server/test/__artifacts__/desk.json @@ -126,5 +126,46 @@ "tag": "Server.DeskError", "message": "The focus order must name every instance once" } - ] + ], + "afterRestart": { + "workspaces": [ + { + "name": "Main", + "auto": false, + "pos": 0 + }, + { + "name": "Code", + "auto": false, + "pos": 1 + }, + { + "name": "Notes", + "auto": false, + "pos": 2 + }, + { + "name": "Kept", + "auto": true, + "pos": 3 + } + ], + "desk": { + "instances": [ + { + "id": "notes-2", + "kind": "notes", + "tags": 8, + "props": {}, + "pinned": false + } + ], + "tileOrder": [ + "notes-2" + ], + "focusOrder": [ + "notes-2" + ] + } + } } diff --git a/apps/server/test/desk.test.ts b/apps/server/test/desk.test.ts index fe33b2b2..dec33507 100644 --- a/apps/server/test/desk.test.ts +++ b/apps/server/test/desk.test.ts @@ -16,9 +16,9 @@ const home = mkdtempSync(join(tmpdir(), "codework-desk-")); let server: ChildProcess; let port: number; -beforeAll(async () => { - // CODEWORK_HOME_DIR keeps the server from finding a real CodeWork service; desk and workspace - // RPCs never need one. +// CODEWORK_HOME_DIR keeps the server from finding a real CodeWork service; desk and workspace +// RPCs never need one. The same database is reused across starts. +async function start() { server = spawn(process.execPath, [entry], { env: { ...process.env, CODEWORK_DATABASE: join(home, "app.db"), CODEWORK_HOME_DIR: home }, stdio: ["ignore", "pipe", "inherit"], @@ -30,13 +30,19 @@ beforeAll(async () => { if (message.type === "ready" && message.port !== undefined) resolve(message.port); }); }); -}, 20_000); +} -afterAll(async () => { +async function stop() { const exited = new Promise((resolve) => server.once("exit", resolve)); // The server does not yet stop on SIGTERM while no CodeWork service is registered. server.kill("SIGKILL"); await exited; +} + +beforeAll(start, 20_000); + +afterAll(async () => { + await stop(); rmSync(home, { recursive: true, force: true }); }); @@ -86,6 +92,15 @@ it("desk round-trips and follows workspace deletion", async () => { focusOrder: [...afterDelete.focusOrder, ...afterDelete.focusOrder], }).pipe(Effect.flip); + // Two auto workspaces: one keeps a frame, the other is left empty without being closed. + const kept = yield* rpc["workspaces.create"]({ name: "Kept", icon: "box", auto: true }); + yield* rpc["workspaces.create"]({ name: "Stray", icon: "box", auto: true }); + yield* rpc["desk.save"]({ + instances: [{ id: "notes-2", kind: "notes", tags: 1 << kept.bit, props: {}, pinned: false }], + tileOrder: ["notes-2"], + focusOrder: ["notes-2"], + }); + return { empty, workspace: { ...workspace, id: "<workspace>" }, @@ -98,5 +113,19 @@ it("desk round-trips and follows workspace deletion", async () => { expect(steps.roundTrip).toEqual(steps.saved); expect(steps.afterDelete.instances).toEqual([{ ...steps.saved.instances[0], tags: 1 }]); - await expect(`${JSON.stringify(steps, null, "\t")}\n`).toMatchFileSnapshot("./__artifacts__/desk.json"); -}, 20_000); + + // A restart sweeps the empty auto workspace and keeps the one with a frame. + await stop(); + await start(); + const afterRestart = await Effect.gen(function* () { + const rpc = yield* RpcClient.make(Contract.Api); + const workspaces = yield* rpc["workspaces.list"](); + const desk = yield* rpc["desk.get"](); + return { workspaces: workspaces.map(({ name, auto, pos }) => ({ name, auto, pos })), desk }; + }).pipe(Effect.scoped, Effect.provide(protocol()), Effect.runPromise); + expect(afterRestart.workspaces.map((workspace) => workspace.name)).toEqual(["Main", "Code", "Notes", "Kept"]); + + await expect(`${JSON.stringify({ ...steps, afterRestart }, null, "\t")}\n`).toMatchFileSnapshot( + "./__artifacts__/desk.json", + ); +}, 30_000); From 76f19f0a0f9536dc558ad3fab6e6818d775b92bf Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 17 Oct 2026 18:56:38 +0530 Subject: [PATCH 14/20] fix(web): leave an emptied auto workspace without a flash Moving or closing the last frame of an auto workspace retagged the frame while that workspace was still on screen, so it rendered empty before the app navigated, then the tab vanished. The frame now bridges both workspaces until the destination shows, the desk is saved, and the empty source is deleted afterwards. Desk saves run in order and read the atom, not a render, so a delete never outruns the save it needs. New workspace tabs fade in. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- apps/web/src/shell/switcher.tsx | 2 +- apps/web/src/wm/desktop.tsx | 47 +++++++++++++++++++----------- apps/web/src/wm/save.ts | 51 +++++++++++++++++++-------------- apps/web/src/wm/workspaces.ts | 3 +- 4 files changed, 63 insertions(+), 40 deletions(-) diff --git a/apps/web/src/shell/switcher.tsx b/apps/web/src/shell/switcher.tsx index e0191fb9..bd99dbdf 100644 --- a/apps/web/src/shell/switcher.tsx +++ b/apps/web/src/shell/switcher.tsx @@ -73,7 +73,7 @@ export function Switcher({ workspaces, active }: SwitcherProps) { {...workspaceRoute(workspace)} aria-current={current ? "page" : undefined} title={`${tab.title}${tab.subtitle === undefined ? "" : `\n${tab.subtitle}`}\n${shortcutLabel(index + 1)}`} - className={`flex h-9 w-56 min-w-12 shrink items-center gap-2 rounded-[10px] pr-3 pl-1.5 transition-colors [corner-shape:superellipse(1.25)] ${ + className={`flex h-9 w-56 min-w-12 shrink items-center gap-2 rounded-[10px] pr-3 pl-1.5 transition-colors animate-in duration-150 ease-out fade-in-0 zoom-in-95 [corner-shape:superellipse(1.25)] ${ current ? "border border-edge bg-linear-to-b from-frame-top to-frame text-ink shadow-frame" : "border border-transparent text-ink-muted hover:bg-ink/5 hover:text-ink" diff --git a/apps/web/src/wm/desktop.tsx b/apps/web/src/wm/desktop.tsx index 6b0dd8a1..f45810e7 100644 --- a/apps/web/src/wm/desktop.tsx +++ b/apps/web/src/wm/desktop.tsx @@ -23,7 +23,7 @@ import { type Chrome, chromeAtom, chromeOf, focusedAtom, tabOwnerAtom } from "./ import { Divider } from "./divider"; import { Frame, FRAME_HEADER_HEIGHT } from "./frame"; import { Overflow } from "./overflow"; -import { add, deskAtom, type Instance, remove, retarget, focus as raise, update } from "./instances"; +import { add, type Desk, deskAtom, type Instance, remove, retarget, focus as raise, update } from "./instances"; import { resolve } from "./resolver"; import { useSaveDesk } from "./save"; import { @@ -124,7 +124,7 @@ export function Desktop({ workspace }: DesktopProps) { const [views, setViews] = useState<ReadonlyMap<string, View>>(() => new Map()); const [chrome, setChrome] = useAtom(chromeAtom); const [drag, setDrag] = useState<Drag | null>(null); - useSaveDesk(drag !== null); + const flushDesk = useSaveDesk(drag !== null); const [resizing, setResizing] = useState(false); const [ref, size] = useElementSize<HTMLDivElement>(); // A window resize must not tween: frames would trail the window edge by the transition's length. @@ -244,35 +244,48 @@ export function Desktop({ workspace }: DesktopProps) { return next; }); - // An auto workspace exists to show what was opened into it, so it goes with its last frame. - const leaving = (instanceId: string, to: { readonly to: "/" } | ReturnType<typeof workspaceRoute>) => { + // Applies `change`, which takes the frame off this workspace. An auto workspace exists to show what + // was opened into it, so it goes with its last frame, quietly: the app goes to `to` with the desk at + // `bridge` (the frame still on screen) and changes it only then, so nothing passes through an empty + // workspace. The desk is saved before the delete, which drops widgets the workspace alone held. + const leave = ( + instanceId: string, + change: (desk: Desk) => Desk, + to: { readonly to: "/" } | ReturnType<typeof workspaceRoute>, + bridge: (desk: Desk) => Desk = (current) => current, + ) => { const leaver = byId.get(instanceId); const emptied = leaver !== undefined && (leaver.tags & activeTags) !== 0 && !instances.some((instance) => instance.id !== instanceId && (instance.tags & activeTags) !== 0); - // Leaving only once the delete lands: leaving first unmounts this and drops the list's refresh. - if (workspace.auto && emptied) { - void removeWorkspace({ payload: { id: workspace.id }, reactivityKeys: workspacesKey }).then( - () => navigate(to), - () => undefined, - ); - } + if (!workspace.auto || !emptied) return setDesk(change); + setDesk(bridge); + void navigate(to) + .then(() => { + setDesk(change); + return flushDesk(); + }) + .then(() => removeWorkspace({ payload: { id: workspace.id }, reactivityKeys: workspacesKey })) + .catch(() => undefined); }; const close = (instanceId: string) => { - leaving(instanceId, { to: "/" }); - setDesk((current) => - current.instances.some(({ id }) => id === instanceId) ? remove(current, instanceId) : current, + leave( + instanceId, + (current) => (current.instances.some(({ id }) => id === instanceId) ? remove(current, instanceId) : current), + { to: "/" }, ); undecorate(instanceId); }; - // Moves a frame off this workspace onto another, focused there; this workspace stays on screen. + // Moves a frame off this workspace onto another, focused there; this workspace stays on screen + // unless the frame was all it showed. const move = (instanceId: string, destination: Workspace) => { - leaving(instanceId, workspaceRoute(destination)); + const tag = workspaceTag(destination); + const retag = (tags: number) => (current: Desk) => raise(update(current, instanceId, { tags }), instanceId); setDrag(null); - setDesk((current) => raise(update(current, instanceId, { tags: workspaceTag(destination) }), instanceId)); + leave(instanceId, retag(tag), workspaceRoute(destination), retag((byId.get(instanceId)?.tags ?? 0) | tag)); }; // A new workspace is auto, so it goes once the frame leaves it; it is named like the frame's tab would be. diff --git a/apps/web/src/wm/save.ts b/apps/web/src/wm/save.ts index e7bf8a5c..593302e9 100644 --- a/apps/web/src/wm/save.ts +++ b/apps/web/src/wm/save.ts @@ -1,5 +1,5 @@ -import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import { useCallback, useEffect, useRef } from "react"; +import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { useCallback, useContext, useEffect, useRef } from "react"; import { type Desk, deskAtom, loadedDeskAtom, saveDesk, toSaved } from "./instances"; @@ -7,39 +7,48 @@ const DELAY = 300; /** * Saves the desk once edits settle, holding off while `paused` (a frame mid-drag). Leaving the page - * or unmounting saves at once. The desk as loaded, or as last saved, is never sent again. + * or unmounting saves at once. The desk as loaded, or as last saved, is never sent again. The + * returned `flush` saves the desk now. */ export function useSaveDesk(paused: boolean) { const desk = useAtomValue(deskAtom); - const loaded = useAtomValue(loadedDeskAtom); - const save = useAtomSet(saveDesk); - const latest = useRef({ desk, loaded }); + const registry = useContext(RegistryContext); + const save = useAtomSet(saveDesk, { mode: "promise" }); const sent = useRef<Desk>(undefined); + // One save at a time, in order: a later desk must never be overwritten by an earlier one landing late. + const queue = useRef(Promise.resolve()); - useEffect(() => { - latest.current = { desk, loaded }; - }); - + // Read from the registry, not a render: a desk just set, or set after unmounting, has not rendered. const flush = useCallback(() => { - const { desk, loaded } = latest.current; - if (loaded === undefined || desk === loaded || desk === sent.current) return; - sent.current = desk; - save({ payload: toSaved(desk) }); - }, [save]); + const next = registry.get(deskAtom); + const loaded = registry.get(loadedDeskAtom); + if (loaded === undefined || next === loaded || next === sent.current) return queue.current; + sent.current = next; + queue.current = queue.current.then(() => + save({ payload: toSaved(next) }).then( + () => undefined, + () => undefined, + ), + ); + return queue.current; + }, [registry, save]); useEffect(() => { if (paused) return; - const timer = setTimeout(flush, DELAY); + const timer = setTimeout(() => void flush(), DELAY); return () => clearTimeout(timer); }, [desk, paused, flush]); useEffect(() => { - window.addEventListener("pagehide", flush); - window.addEventListener("beforeunload", flush); + const now = () => void flush(); + window.addEventListener("pagehide", now); + window.addEventListener("beforeunload", now); return () => { - window.removeEventListener("pagehide", flush); - window.removeEventListener("beforeunload", flush); - flush(); + window.removeEventListener("pagehide", now); + window.removeEventListener("beforeunload", now); + now(); }; }, [flush]); + + return flush; } diff --git a/apps/web/src/wm/workspaces.ts b/apps/web/src/wm/workspaces.ts index ef165850..6e0f08db 100644 --- a/apps/web/src/wm/workspaces.ts +++ b/apps/web/src/wm/workspaces.ts @@ -19,7 +19,8 @@ export const workspacesAtom = Server.query("workspaces.list", undefined, { react export const createWorkspace = Server.mutation("workspaces.create"); export const updateWorkspace = Server.mutation("workspaces.update"); export const reorderWorkspaces = Server.mutation("workspaces.reorder"); -export const deleteWorkspace = Server.mutation("workspaces.delete"); +// Kept alive so a delete started as the desktop unmounts (its last frame closed) still lands. +export const deleteWorkspace = Server.mutation("workspaces.delete").pipe(Atom.keepAlive); /** Workspaces in switcher order; empty until the server answers. */ export function useWorkspaces(): readonly Workspace[] { From f890690adba30127301cf8357f3c006f11cfb8ac Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 17 Oct 2026 19:17:01 +0530 Subject: [PATCH 15/20] fix(desktop): show CodeWork in the macOS Dock in dev The Dock labels an app from its bundle's Info.plist, so dev runs Electron from a renamed copy of Electron.app, rebuilt when Electron changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .gitignore | 1 + apps/desktop/scripts/start-electron.mjs | 32 +++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 1a72c8ec..46b4c5bb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules .pnpm-store out +.electron-runtime dist build .next diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index 3eeb31a6..e9f75831 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -1,10 +1,38 @@ -import { spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; +import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; const desktopDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const electronPath = createRequire(import.meta.url)("electron"); +const electronBinary = createRequire(import.meta.url)("electron"); +const APP_NAME = "CodeWork"; + +// macOS names the Dock icon after the running bundle's Info.plist, so on macOS +// Electron runs from a renamed copy of Electron.app, rebuilt when Electron changes. +function brandedElectron() { + const source = path.resolve(electronBinary, "../../.."); + const target = path.join(desktopDir, ".electron-runtime", `${APP_NAME}.app`); + const marker = path.join(desktopDir, ".electron-runtime", "source"); + const binary = path.join(target, "Contents", "MacOS", "Electron"); + if (existsSync(marker) && readFileSync(marker, "utf8") === source) return binary; + + rmSync(target, { recursive: true, force: true }); + // Keeps the frameworks' relative symlinks; absolute ones crash the helper processes. + cpSync(source, target, { recursive: true, verbatimSymlinks: true }); + const plist = path.join(target, "Contents", "Info.plist"); + for (const [key, value] of [ + ["CFBundleName", APP_NAME], + ["CFBundleDisplayName", APP_NAME], + ["CFBundleIdentifier", "sh.codework.desktop.dev"], + ]) + execFileSync("plutil", ["-replace", key, "-string", value, plist]); + execFileSync("codesign", ["--force", "--deep", "--sign", "-", target]); + writeFileSync(marker, source); + return binary; +} + +const electronPath = process.platform === "darwin" ? brandedElectron() : electronBinary; const env = { ...process.env }; delete env.ELECTRON_RUN_AS_NODE; From cf6bbddbee4f2602a0fc63ca6218ea27b243c508 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sun, 18 Oct 2026 00:39:59 +0530 Subject: [PATCH 16/20] feat(desktop): add a Servers settings page for the local CodeWork server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › Servers lists the local server, found from its codework binary, with its name and version. Its detail shows the server's state with Start/Restart/Stop through `codework service`, and lets the name, binary, CODEWORK_HOME_DIR, launch args and environment variables be set; all of them are passed when the server starts. Sensitive variables never leave the app server. A new home reconnects the app at once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/server/src/cli.ts | 247 ++++++++++++ apps/server/src/codework.ts | 113 +++++- apps/server/src/contract.ts | 81 ++++ apps/server/src/handlers.ts | 2 + apps/server/src/server.ts | 5 +- apps/web/src/assets/codework.png | Bin 0 -> 8576 bytes apps/web/src/lib/utils.ts | 6 + apps/web/src/routeTree.gen.ts | 21 ++ .../src/routes/_shell.settings.servers.tsx | 5 + apps/web/src/settings/blocks.tsx | 56 ++- apps/web/src/settings/sections.ts | 11 +- apps/web/src/settings/servers/local.tsx | 350 ++++++++++++++++++ apps/web/src/settings/servers/servers.tsx | 72 ++++ apps/web/src/settings/servers/variables.tsx | 133 +++++++ apps/web/src/settings/workspaces.tsx | 47 +-- apps/web/src/style.css | 4 + 16 files changed, 1105 insertions(+), 48 deletions(-) create mode 100644 apps/server/src/cli.ts create mode 100644 apps/web/src/assets/codework.png create mode 100644 apps/web/src/routes/_shell.settings.servers.tsx create mode 100644 apps/web/src/settings/servers/local.tsx create mode 100644 apps/web/src/settings/servers/servers.tsx create mode 100644 apps/web/src/settings/servers/variables.tsx diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts new file mode 100644 index 00000000..074ecf4b --- /dev/null +++ b/apps/server/src/cli.ts @@ -0,0 +1,247 @@ +import { Config, Effect, FileSystem, Option, Schedule, Schema, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; +/* @effect-diagnostics nodeBuiltinImport:off -- the install folder sits under the OS home directory. */ +import os from "node:os"; +import path from "node:path"; +import { Codework } from "./codework.ts"; +import { + type CliAction, + type CliConfig, + type CliEnvVar, + CliError, + type CliInstall, + type CliServer, +} from "./contract.ts"; +import { Kv } from "./kv.ts"; + +const executableKey = { package: "codework", key: "servers.local.executable" } as const; +const envKey = { package: "codework", key: "servers.local.env" } as const; +const argsKey = { package: "codework", key: "servers.local.args" } as const; + +const savedArgs = Effect.gen(function* () { + const saved = yield* (yield* Kv.Service).get(argsKey.package, argsKey.key); + return Option.getOrElse( + Option.filter(saved, (value): value is string => typeof value === "string"), + () => "", + ); +}); + +/** 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] ?? ""); + +// Stored with every value; only the app server reads the sensitive ones. +const StoredEnv = Schema.Array(Schema.Struct({ name: Schema.String, value: Schema.String, sensitive: Schema.Boolean })); +type StoredEnv = typeof StoredEnv.Type; + +const savedEnv = Effect.gen(function* () { + const saved = yield* (yield* Kv.Service).get(envKey.package, envKey.key); + return Option.getOrElse(Option.flatMap(saved, Schema.decodeUnknownOption(StoredEnv)), (): StoredEnv => []); +}); + +const VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** + * The variables as chosen: names must be shell names, once each, and leave the home to its own + * setting; a sensitive one sent without a value keeps the saved value. + */ +const nextEnv = (chosen: ReadonlyArray<CliEnvVar>, saved: StoredEnv) => + Effect.gen(function* () { + const names = new Set<string>(); + const next: Array<StoredEnv[number]> = []; + for (const { name: raw, sensitive, value } of chosen) { + const name = raw.trim(); + if (!VARIABLE_NAME.test(name)) + return yield* new CliError({ + message: `${name || "A blank name"} is not a variable name: use letters, digits and _.`, + }); + if (name === "CODEWORK_HOME_DIR") + return yield* new CliError({ message: "Set CODEWORK_HOME_DIR with the home path above." }); + if (names.has(name)) return yield* new CliError({ message: `${name} is set twice.` }); + names.add(name); + // Only a variable that stays sensitive keeps its saved value, so none is ever revealed by unmarking it. + const kept = sensitive + ? saved.find((variable) => variable.name === name && variable.sensitive)?.value + : undefined; + next.push({ name, sensitive, value: value ?? kept ?? "" }); + } + return next; + }); + +// 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")), + Effect.orDie, +); + +/** + * Runs `file` to completion, with stdout and stderr together; a spawn failure or timeout is a failure. + * `env` is added to this process's own. + */ +const run = ( + file: string, + args: ReadonlyArray<string>, + timeout: `${number} seconds`, + env: Record<string, string> = {}, +) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const handle = yield* spawner.spawn(ChildProcess.make(file, args, { stdin: "ignore", env, extendEnv: true })); + const [output, code] = yield* Effect.all( + [handle.all.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], + { + concurrency: "unbounded", + }, + ); + return { output: output.trim(), code: Number(code) }; + }).pipe(Effect.scoped, Effect.timeout(timeout)); + +// A Node crash prints the source line first; the error line says what went wrong. +const reason = (output: string, code: number) => { + const lines = output.split("\n").map((line) => line.trim()); + const line = lines.findLast((candidate) => /error/i.test(candidate)) ?? lines.findLast(Boolean); + return line === undefined ? `exited with code ${code}` : `exited with code ${code}: ${line.slice(0, 240)}`; +}; + +// An app opened from the Dock does not inherit the terminal's PATH, so the login shell is asked. +const onPath = Effect.gen(function* () { + const shell = process.env.SHELL ?? "/bin/sh"; + const { output } = yield* run(shell, ["-ilc", "command -v codework"], "5 seconds"); + return output + .split("\n") + .map((line) => line.trim()) + .findLast((line) => line.startsWith("/")); +}).pipe(Effect.orElseSucceed(() => undefined)); + +const probe = (file: string, custom: boolean) => + run(file, ["--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) }; + }), + Effect.catch((error) => Effect.succeed<CliInstall>({ path: file, custom, failure: error.message })), + ); + +const chosenExecutable = Effect.gen(function* () { + const saved = yield* (yield* Kv.Service).get(executableKey.package, executableKey.key); + return Option.getOrUndefined(Option.filter(saved, (value): value is string => typeof value === "string")); +}); + +/** + * The `codework` the app uses: the one chosen in Settings; else the install script's, else the login + * shell's, whichever runs first; when neither runs, the first found, with why. + */ +const executable = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exists = (file: string) => fs.exists(file).pipe(Effect.orElseSucceed(() => false)); + const chosen = yield* chosenExecutable; + if (chosen !== undefined) + return (yield* exists(chosen)) + ? yield* probe(chosen, true) + : ({ path: chosen, custom: true, failure: "No file at this path." } satisfies CliInstall); + const candidates = [ + path.join(yield* installDir, "codework"), + ...Option.toArray(Option.fromUndefinedOr(yield* onPath)), + ]; + let broken: CliInstall | null = null; + for (const file of candidates) { + if (!(yield* exists(file))) continue; + const found = yield* probe(file, false); + if ("version" in found) return found; + broken ??= found; + } + return broken; +}); + +/** A path typed in Settings: `~` is the user's home; blank means none; anything else must be absolute. */ +const chosenPath = (input: string | null) => + Effect.gen(function* () { + const trimmed = input?.trim() ?? ""; + if (trimmed === "") return null; + const expanded = + trimmed === "~" || trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(1)) : trimmed; + if (!path.isAbsolute(expanded)) + return yield* new CliError({ message: "Use a full path, such as ~/.codework or /usr/local/bin/codework." }); + return path.normalize(expanded); + }); + +const alive = (pid: number) => + Effect.sync(() => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM: it exists, under another user. + return (error as NodeJS.ErrnoException).code === "EPERM"; + } + }); + +/** + * The registered server, asked through the app's own connection to it. A build whose replies differ + * still answers `server.info`, so a call the app depends on decides whether it works with this app. + */ +const server = Effect.gen(function* () { + const registered = yield* Codework.registration; + if (Option.isNone(registered) || !(yield* alive(registered.value.pid))) return { _tag: "Stopped" } as const; + const { url, pid } = registered.value; + const info = yield* Codework.call((client) => client("server.info", {})).pipe(Effect.option); + if (Option.isNone(info) || info.value.pid !== pid) return { _tag: "Unreachable", url, pid } satisfies CliServer; + const { version } = info.value; + const usable = yield* Codework.call((client) => client("session.list", {})).pipe(Effect.isSuccess); + return { _tag: usable ? "Running" : "Incompatible", url, pid, version } satisfies CliServer; +}); + +// 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. +const awaitConnection = Codework.call((client) => client("server.info", {})).pipe( + Effect.retry(Schedule.spaced("250 millis")), + Effect.timeout("15 seconds"), + Effect.ignore, +); + +export const handlers = { + "cli.status": Effect.fn("cli.status")(function* () { + const [cli, registered] = yield* Effect.all([executable, server], { concurrency: "unbounded" }); + const env = (yield* savedEnv).map(({ name, sensitive, value }) => + sensitive ? { name, sensitive } : { name, sensitive, value }, + ); + return { home: yield* (yield* Codework.Home).get, cli, server: registered, args: yield* savedArgs, env }; + }), + + "cli.configure": Effect.fn("cli.configure")(function* (config: typeof CliConfig.Type) { + if (config.executable !== undefined) { + const chosen = yield* chosenPath(config.executable); + yield* (yield* Kv.Service).set(executableKey.package, executableKey.key, chosen); + } + if (config.args !== undefined) yield* (yield* Kv.Service).set(argsKey.package, argsKey.key, config.args.trim()); + if (config.env !== undefined) { + const next = yield* nextEnv(config.env, yield* savedEnv); + yield* (yield* Kv.Service).set(envKey.package, envKey.key, next); + } + if (config.home !== undefined) { + yield* (yield* Codework.Home).set(yield* chosenPath(config.home)); + if (Option.isSome(yield* Codework.registration)) yield* awaitConnection; + } + }), + + // The CLI owns the server's lifecycle; the app only asks it, with the variables chosen and in the + // home the app uses, which a variable never overrides. + "cli.service": Effect.fn("cli.service")(function* ({ action }: { readonly action: CliAction }) { + const cli = yield* executable; + if (cli === null || !("version" in cli)) + return yield* new CliError({ message: "No working codework CLI was found." }); + const home = (yield* (yield* Codework.Home).get).path; + const env = Object.fromEntries((yield* savedEnv).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(yield* savedArgs); + const { output, code } = yield* run(cli.path, ["service", action, ...args], "30 seconds", { + ...env, + CODEWORK_HOME_DIR: home, + }).pipe(Effect.mapError((error) => new CliError({ message: `codework service ${action}: ${error.message}` }))); + if (code !== 0) return yield* new CliError({ message: `codework service ${action} ${reason(output, code)}` }); + if (action !== "stop") yield* awaitConnection; + }), +}; + +export * as Cli from "./cli.ts"; diff --git a/apps/server/src/codework.ts b/apps/server/src/codework.ts index fd84c66a..7aad07fa 100644 --- a/apps/server/src/codework.ts +++ b/apps/server/src/codework.ts @@ -1,12 +1,26 @@ import { Codework as Mirror } from "@codeworksh/widget/codework"; import { Codework as Client } from "@codeworksh/widget/backend"; import { NodeSocket } from "@effect/platform-node"; -import { Config, Effect, FileSystem, Layer, Schedule, Schema } from "effect"; +import { + Config, + Context, + Deferred, + Effect, + FileSystem, + Layer, + Option, + Schedule, + Schema, + Stream, + SubscriptionRef, +} from "effect"; import { RpcClient, RpcSerialization } from "effect/rpc"; +import { Socket } from "effect/socket"; /* @effect-diagnostics nodeBuiltinImport:off -- the default home sits under the OS home directory. */ import os from "node:os"; import path from "node:path"; import { CodeworkUnavailable } from "./contract.ts"; +import { Kv } from "./kv.ts"; export type SessionInfo = Mirror.SessionInfo; @@ -15,30 +29,105 @@ const Registration = Schema.fromJsonString( Schema.Struct({ pid: Schema.Int, version: Schema.String, url: Schema.String, port: Schema.Int }), ); -// The same home the CLI resolves: CODEWORK_HOME_DIR, else ~/.codework. -const home = Config.String("CODEWORK_HOME_DIR").pipe(Config.withDefault(path.join(os.homedir(), ".codework"))); +/** The same home the CLI resolves: CODEWORK_HOME_DIR, else ~/.codework. */ +const cliHome = Config.String("CODEWORK_HOME_DIR").pipe(Config.withDefault(path.join(os.homedir(), ".codework"))); + +const homeKey = { package: "codework", key: "servers.local.home" } as const; + +/** The CodeWork home the app connects through: the one chosen in Settings, else the CLI's own. */ +export class Home extends Context.Service< + Home, + { + /** The home in use; `fallback` is the one used when none is chosen. */ + readonly get: Effect.Effect<{ readonly path: string; readonly custom: boolean; readonly fallback: string }>; + /** Saves the choice; `null` goes back to the CLI's home. */ + readonly set: (path: string | null) => Effect.Effect<void>; + /** The home's path each time the choice changes, without the current one. */ + readonly changes: Stream.Stream<string>; + } +>()("@codeworksh/server/codework/Home") {} + +export const homeLayer = Layer.effect( + Home, + Effect.gen(function* () { + const kv = yield* Kv.Service; + const fallback = yield* cliHome.pipe(Effect.orDie); + const saved = yield* kv.get(homeKey.package, homeKey.key); + const chosen = yield* SubscriptionRef.make( + Option.flatMap(saved, (value) => (typeof value === "string" ? Option.some(value) : Option.none())), + ); + const get = SubscriptionRef.get(chosen).pipe( + Effect.map( + Option.match({ + onNone: () => ({ path: fallback, custom: false, fallback }), + onSome: (chosenPath) => ({ path: chosenPath, custom: true, fallback }), + }), + ), + ); + return Home.of({ + get, + set: (next) => + kv + .set(homeKey.package, homeKey.key, next) + .pipe(Effect.andThen(SubscriptionRef.set(chosen, Option.fromNullOr(next)))), + changes: SubscriptionRef.changes(chosen).pipe( + Stream.drop(1), + Stream.mapEffect(() => get), + Stream.map((current) => current.path), + ), + }); + }), +); + +/** The server the home's `service.json` names, read fresh; a missing or unreadable file is none. */ +export const registration = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const file = path.join((yield* (yield* Home).get).path, "service.json"); + return yield* fs.readFileString(file).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Registration)), Effect.option); +}); + +/** + * The connection ends when the home changes, as if the server went away: the client reconnects, and + * so reads the new home's registration, and its callers resubscribe as after any drop. + */ +const endOnHomeChange = (socket: Socket.Socket, changes: Stream.Stream<string>): Socket.Socket => ({ + ...socket, + // Watched from when the connection opens, so a change between two reads still ends it. + reader: Effect.gen(function* () { + const reader = yield* socket.reader; + const changed = yield* Deferred.make<void>(); + yield* Stream.runHead(changes).pipe(Effect.andThen(Deferred.succeed(changed, undefined)), Effect.forkScoped); + const closed = Deferred.await(changed).pipe( + Effect.andThen(Effect.fail(new Socket.SocketError({ reason: new Socket.SocketCloseError({ code: 1000 }) }))), + ); + return { ...reader, pull: reader.pull.pipe(Effect.raceFirst(closed)) }; + }), +}); /** * The app's one connection to the CodeWork server. The registration is read on every connect, so a - * server restarted on a new port is picked up without restarting this app; until one is - * registered, connecting waits. + * server restarted on a new port, or a new home, is picked up without restarting this app; until + * one is registered, connecting waits. */ export const layer = Layer.effect(Client, RpcClient.make(Mirror.Api, { flatten: true })).pipe( Layer.provide(RpcClient.layerProtocolSocket({ retryTransientErrors: true })), Layer.provide( - Layer.unwrap( + Layer.effect( + Socket.Socket, Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const file = path.join(yield* home, "service.json"); - const url = fs.readFileString(file).pipe( - Effect.flatMap(Schema.decodeUnknownEffect(Registration)), - Effect.map((registration) => registration.url), + const home = yield* Home; + const url = registration.pipe( + Effect.flatMap(Effect.fromOption), + Effect.map((registered) => registered.url), Effect.retry(Schedule.spaced("1 second")), Effect.orDie, + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Home, home), ); - return NodeSocket.layerWebSocket(url); + return endOnHomeChange(yield* Socket.makeWebSocket(url), home.changes); }), - ), + ).pipe(Layer.provide(NodeSocket.layerWebSocketConstructor)), ), Layer.provide(RpcSerialization.layerNdjson), Layer.orDie, diff --git a/apps/server/src/contract.ts b/apps/server/src/contract.ts index 51fb0675..61861e35 100644 --- a/apps/server/src/contract.ts +++ b/apps/server/src/contract.ts @@ -102,6 +102,72 @@ export class DeskError extends Schema.TaggedError<DeskError>()("Server.DeskError message: Schema.String, }) {} +/** + * The `codework` executable the app uses, and its `--version`, or why that failed. `custom` when it + * is the path chosen in Settings rather than the one found. + */ +export const CliInstall = Schema.Union([ + Schema.Struct({ path: Schema.String, custom: Schema.Boolean, version: Schema.String }), + Schema.Struct({ path: Schema.String, custom: Schema.Boolean, failure: Schema.String }), +]); +export type CliInstall = typeof CliInstall.Type; + +/** + * The CodeWork server registered in the home. `Incompatible` answers but its replies do not decode + * here: another build, even one with the same version. + */ +export const CliServer = Schema.Union([ + Schema.TaggedStruct("Running", { url: Schema.String, pid: Schema.Int, version: Schema.String }), + Schema.TaggedStruct("Incompatible", { url: Schema.String, pid: Schema.Int, version: Schema.String }), + Schema.TaggedStruct("Stopped", {}), + Schema.TaggedStruct("Unreachable", { url: Schema.String, pid: Schema.Int }), +]); +export type CliServer = typeof CliServer.Type; + +/** + * A variable the server is started with. A sensitive one's value never leaves the app server, so + * it comes without `value`; sent back without one, the saved value stays. + */ +export const CliEnvVar = Schema.Struct({ + name: Schema.String, + sensitive: Schema.Boolean, + value: Schema.optionalKey(Schema.String), +}); +export type CliEnvVar = typeof CliEnvVar.Type; + +export const CliStatus = Schema.Struct({ + /** + * The CodeWork home whose `service.json` names the server; `custom` when chosen in Settings, and + * `fallback` the one used otherwise. + */ + home: Schema.Struct({ path: Schema.String, custom: Schema.Boolean, fallback: Schema.String }), + /** None when no `codework` was found. */ + cli: Schema.NullOr(CliInstall), + server: CliServer, + /** Extra arguments for `codework service start` and `restart`, as typed. */ + args: Schema.String, + env: Schema.Array(CliEnvVar), +}); +export type CliStatus = typeof CliStatus.Type; + +export const CliAction = Schema.Literals(["start", "restart", "stop"]); +export type CliAction = typeof CliAction.Type; + +/** + * What is chosen in Settings; an absent field is left as it is. A `null` path goes back to the one + * found; `env` replaces every variable. + */ +export const CliConfig = Schema.Struct({ + executable: Schema.optionalKey(Schema.NullOr(Schema.String)), + home: Schema.optionalKey(Schema.NullOr(Schema.String)), + args: Schema.optionalKey(Schema.String), + env: Schema.optionalKey(Schema.Array(CliEnvVar)), +}); + +export class CliError extends Schema.TaggedError<CliError>()("Server.CliError", { + message: Schema.String, +}) {} + /** Package-scoped JSON storage: `package` namespaces keys so packages never collide. */ const KvKey = { package: Schema.String, key: Schema.String }; @@ -149,6 +215,21 @@ export const Api = RpcGroup.make( success: Schema.Void, error: DeskError, }), + Rpc.make("cli.status", { + success: CliStatus, + }), + /** Saves the executable and home chosen in Settings; the app reconnects through a new home at once. */ + Rpc.make("cli.configure", { + payload: CliConfig, + success: Schema.Void, + error: CliError, + }), + /** Runs `codework service <action>` with the CLI in use, in the home in use. */ + Rpc.make("cli.service", { + payload: { action: CliAction }, + success: Schema.Void, + error: CliError, + }), Rpc.make("kv.get", { payload: KvKey, success: Schema.OptionFromNullOr(Schema.Json), diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 4b146247..3df68ba3 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -1,4 +1,5 @@ import { Effect } from "effect"; +import { Cli } from "./cli.ts"; import { Codework } from "./codework.ts"; import { Contract, ModelsError, type Project } from "./contract.ts"; import { Desk } from "./desk.ts"; @@ -43,6 +44,7 @@ export const layer = Contract.Api.toLayer( return Contract.Api.of({ ...workspaces, ...desk, + ...Cli.handlers, "projects.list": Effect.fn("projects.list")(function* () { const sessions = yield* Codework.call((client) => client("session.list", {})); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1a401800..2e54dc4a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -31,7 +31,10 @@ const rpc = RpcServer.layerHttp({ group: Contract.Api, path: "/rpc", protocol: " // One event log, kv and CodeWork connection for the RPC handlers and the widget backends alike. export const layer = Layer.mergeAll(HttpRouter.serve(rpc, { disableListenLog: true }), Widgets.layer).pipe( - Layer.provide(Layer.mergeAll(Event.layer, Kv.layer, Codework.layer)), + Layer.provide(Layer.mergeAll(Event.layer, Codework.layer)), + // The home is the app's own setting, and its connection goes through it, so both reach the handlers too. + Layer.provideMerge(Codework.homeLayer), + Layer.provideMerge(Kv.layer), Layer.provideMerge(announceReady), Layer.provideMerge(NodeHttpServer.layer(createServer, { host: "127.0.0.1", port: 0 })), Layer.provide(RpcSerialization.layerNdjson), diff --git a/apps/web/src/assets/codework.png b/apps/web/src/assets/codework.png new file mode 100644 index 0000000000000000000000000000000000000000..b038583864343d39fe912c3f93c71791279be4fa GIT binary patch literal 8576 zcmV-`A%EV9P)<h;3K|Lk000e1NJLTq003YB003YJ0ssI2ZTjGE00001b5ch_0Itp) z=>Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91V4wp41ONa40RR91U;qFB0I4%yP5=NP>PbXFRCob>oXwA9$yLQGGpn+? zdkh|9Y%_qsAizSx$OsvM5GyvYN3vM5=P!W&0Rd}RBE$wH*n!0Y*^(`RWmX`|BO5$3 z*vyw_+C5#}Rh7y4opWzQWK|DQneig-J@?#?i1+g4NB8EHpZv_(@$77Kc0B6vVGZ5p zY_mB#JNjwxannr<{G|b4qN7H!)XdX>K#EN|I5!N+c+-~HD<<{U$kiktV1kag`k=}s z#)Lv+P|zb215Hg3xK|h>Xd3~S@Vq8A3v;oWuRUTeIZbjBi@RBt;)xqa1k|)HY8e-a ziJGvfAouuyyBZ@1(5hgIY@J|?JCz$PrC-z?Neo3@Iu77GB`N+!V{%?f3sVjXhlgMt z+=hK)szG4EnAu#^II|{vCZF>b`bZ}<i#O)0@#%Phyl6<Ui5|LyzGNDc_?hB4s>FRD z1y@N-8g5VBiKDg(2N;XRhZ<X6c17JRR7@#1u*LJJwd>^@pdo2*@|zVYJ0Szs$>7lt z433T?dY3|?Jj`NI$8j<#^^j=*TbXMIRqKirKAX@1R1_RMgsM+}!?+lo<OCwkOUn>I z$vBMhlw(weS(^b5rgYPE=%MRX0VVPRdz5hl%1Xi!!+;OjRBcm;u*SAl$&)*gN>-j# z;2v3&n7n2KVl=o3t#v8xBsNGZDtMcF1X_a0G$J!ZY90tP#6w~#wlEb<Igl)mIs}91 z#BFg|3h(}9=Ol&^37!dI8i&X8*-i4rk56YjTLzQ7;>xaVJkjWaUAaoq{`*{VQLHPL z;2<~=Xi6r|ru`)u&)Q*ou{Gf38dR7fDddThF=-F>dUBN=8`AcoOb~sHp~Q6|a<vf- zYE(1IJEIQKq%tAayA5*7mVTj}1{IlUFlfVxfPEVp0Hx@cFGRBuN(0EQUD{leuxGYf zW!ygdC})GLiq)oloFpB(eGat;T?>OtBI5B#u{%~!1sX@+ue4Blna&w$o)aj~*-o!! z(U0mFHfQiv?NTKrZU-og?Nh;1iDDrYr)waH7>yZ*NTe7o2*VH$tmQzv<ii3KJ_0pk zVY25HnV43DSQ^|cx>5X)CXbHB>WWl{5s&23i=IE)>WQkvx-4BqP$OI>Wm!R0^i~8m ztSlQ{@FY`!auNVhg(LIO7Gb%{tjwHg6ucZaMAod~Iea|BHhW|iE(RJ>bPkrzyH?n2 zkixh+>I_&}CjXt$TAisXHVG;Ut11M-pDjYoBNTqs#yoxG?u`nx5F|DYHF?1)7N?EU zF}6C?zyOFcFC+pq)38ToSXW`?iMNxTb&@#x6))1B#^dx7s@4Ll{q5x(a!bWxCaf8F z5`wkYHAu$y0*Vnpic-vQAj$N^#-V_W8!aujyn*KcQ=WvgTo`It=(4#VZako#$SJcN zMJJO`Bc2A#X+v_7M0A9gk`+)d^NrWBhRJi8m9b5oAcEkz(tF}Q<Fz_H#=?xMBQg$* zFuT_zoH^dp4guz|H=~Ee8U>OFY*V3g=qu;oY5*3*8UMVSAE6*B%{aX!i`(eR*ktak zY+az{D0yrZ;=B;H#pEb61mszvFE|3$AM4$*`*jE~dsRo!qgqAHsd1+ep>W2@tn%}g z)!4<zsS-~*#ki96sS$*M4d$2@@rZj2q$R}B7$2BfXg>%T)Ffz6rYz`UA|@}r4yLl4 z2=vrA*A|y@v(<q@Z1tvKyHVeRv*Tg&{OVTs!l)^xx%^V@PSZ7V7gVCaq|u*V#q4vj zP7tQbkPzblleFXE_VDc4ZLv8%g6N0|RAWb`p2&ARcDt?m^Yh)Vr@Qm*`Gp?4i~V*d zxu)kE^%oc0i~VkYx!doh+Fe}i&eitF)eEz~#Be9rg?P_)7aOV6Z_iV#v;BjMx8D5z zpZ?L8-um`ow<k1;QT45tm&zuGs^5@_hje`(l$xbf3$DsBt6aFl?eXKEdh~mL@GF;( zHp=b%Lb2}@oZ?ov`^$5L8t;{`N}{zr+nsxDsZq+b^*W=H!{hc=@u$GYv%~SI9q0u- zI~*8mp;=HA$AcPE2AbaL!9;_@jh+Vuhx~ZlZQlFt8$b1V$#(C4=eWJ_DN2Rhl3UOE zSW0iKlKEx6<-H4;!=PvHMZ*;&;_K_%U-;a|fBz4E@dr<Sz&<%{6wvMM_08d$$VlS3 zItY}mB9?Y@CY{1p`iC0{yq0kyu>``)hc_u>{A`6~e79BT(y3^Q8LV2)JddQ%3fxe< zo1ggHQJeFf|2}G?;UYW0o84yVd{SqNl(bm&GVhaa**J?C@s|IJ$|)SrZf>t%d-FT* zzxTwPycALxr^RL#MV-+osIq!uBfv~NHqQQ}jw1u>Ei@P{z(geo_2P5b+ntsGUs#I+ z8my%C>+?}tb9Z><v$v1m++1IsZB-+R^q~zBbI|+ADTc%*Ep)I<)ErF!NB~B|B~kBn zk6dCDRW^)M@?DS_g`_6coQKSH)MF?DP++c&u_bb@x^}4aG(jk$l<HLiP^v}1jx4Wf zL8BwE;|;rZv(rwOm2GSR3XiF0+d$(Q;gH5PErIpGP`c3c>W~t)OCta^_$<d2PKg46 zO?n_NQrkc(<z;9%V{da#s(|3}R`BP-g4kmCG=po$sb(=4ftUs+pF$is$eSIa3c!K{ zjCtjNw8A$)@5Pe25Jf`h6-FT48vsvd8NV#5eS%AjN)?Butl)Z`0TBR3#Hta67?gxh z0E$3EUw?4PAKl19pq$k4j=nj7I$>1~22b&uCQG7=+_9Lsw8ZA2J!+);pr|lI8+F0U zYh-O}a-u8eV4WeHSeVgYgiJCs@d^bx4E}OQ7IsNVTSwhySjp0bBR3b0+KWm7P)Re& z#8&QfS>l5(J@l-AG)`a|L^Y1KvAA=zsk^+2Sza(!&R|%ksX1HWiI>;D$kJh0ui!~q z`$AVJj^rf|zLYyg6O$sCX29p*cm8%>qLC+zT|SXPJgl<3Mu;3&sAuW3jV)iT@J#W< zBU_H?E%$v;yC}64qnqxV#Vf%sop=_Q^|VSO`(TmPQl5?V1ZNQm<5p73;w6Oi60%Ya zi`n8ZkWj_8&p%nRbTUmyrtvvt5-7c7Mah9sibS*2$3o>Zu0aFuu*ixK)nPoXEr@&H zzSMas02r<&fc4bI@i059oRYL-K|efZU@tg_zzM~92W(Qu)cIwHIunvCPsG8!J3AH; z%e5pxlqi6?5s?N6E6SllIIckj=in^LViC`wi0slw=oCouB6c42C%N7kNF|v<rl*2x zU7mzzL4r1c=8i&f>)tZ_a-t~>9u4D?8)|1{4S4CbIGe2u<%atY0Kv%4S}G)o3+SWS znM$6RfU4;lgkv{z)HGremEgnz54;^9&YU*N8Fzw8GR7*!_U7jHU;pLR>zf<#-`)n` zw+~9&HcH1CLMLlaVI^tq!X@WY<z1lUb-#`VS9YdxFxe*b)?uw1wB7OcnkDJ%)=X<` zrto#<)Gm93Mbb4$7)Us&0ym#K)yASxA=A<kH<8~dp56$>+Q8$@?ajY^^}oLP@9*e6 zXWp;sEtvp`S+{Q#Kpx^(z0F{#L_^knV45l3fh;!WB~;X!>7kz<?6)8L)I+^VW{x>N z95fj78CMz|XvITh`aGxs9Q7Q{*6>OW)NMVYkrQt)dt;&H)I@L-!SRP)ez4!4KYx1A zZNA>J?#}hjC~x+4r2q{j-Qrs_%II`_d{HM;62aWQa28xM>7>57KD__-)yF>hQoUN* zOY$#p8;vZjEehqWpT95xU9QBTcr%&exTZ(u;-{P*I!gzvsaqg$Z8nd7c&~T3&!6%p zm5r~RM~w<EOf#BT{H}SV>c{GI9S}J_g&EIWiK{vEP$v2hp5Gp>pB--=+EHZ=*p6mD z2)yLQd&XzdNi_P{+dBfLq#R}X6Bj0vTo5W><}9seIt#UNE+6dlmR{RO`{?HJ{zhAe z_it*!yO$5;Ml6(^edw!AtsnXG0>I+wkh*}kl}$+1fp`$+a>0iQvek~Ig~vm<$gj|_ zBbV4mmQWXIjR&25YZjcrW*Qx_ce9n&P8VtNb-ZYst&!*Y=!+}#+4*k&)@vWRy>5VA z+Y%cgh9WNRNSZIllvuzLFVdnBLP91QO=@IigPV0&^+D{WLg^Z$(>)+@cyOl!>WZq` zc0(tOAL^KDRkF#WGlR{r(dGH#awq%^=ar*1PfW4avwNvE^%BOLbqUNE*8HR~waD9f z8WEc0R!1+E-YjL~5<0s=oG>%VowTVsD=2G=4LJKSlLVld_YzR#sZKML5*>f~@KB#I zVgf+Vtfxal5Ho}=qN;{sZn@rC`MfGjn+TK!iF8F!Mx{#?9LnM&PzuKW4`B=t4O4%! z3ipr|$dbkhfbX7a=tj^!r87gVr?r)}7fdI=PJeyUe9&@K4oxAG5Lf0tghc&hOd|F} z7n?Dy$okTO98+-I(0kaOX5}k@LRt;2snw|g9dA}GiDMH^);T%}OVMyMr1C3g1pzF) z)1@&ew25>ta@aK@-m2Gjv0*z(4XHi6^Z?66Y`DF=zP^bkVdMx!qL>HP_44vUUlPR3 zJEek*M7`Z{<@OkDN}4N5-6*2$W~3+Cw=MT^2@@?B1fudz@qLYBmCBB??<k-?d_3Rs z+58e-4Tq>z;_YF4o<tz0x_f@~@Zm3g{xkdi#l`;o;$l4b@<Y@8C7R3q#Rpf9|L$+U z_{Qt+>RwE~oti68O;3YaT#gE8kW)+xAU$$DgRI4Hfhk2aMro>@qX*)~qiN%mjCWn( zbSS#2XdktKp(>4zZG~!#QBh?UWV{T6pgp^K_JuF}?w|kJA3wi+>Wh`^^fIXnVS>W1 zz9`V=_W$wf*DrqLf4=;WZ+z{m-_{Cw!C=5o@?`eHCWvtPoitF-jdXe*t;t7FD7jaw z6iDO5utCK^D2~Q%q$Kl<LB?7#kIM#jw;XCxO0s|O==|#V_Twk-p-L=%9VK`KyoKNH zcHevRyN{o~^P9i($sc{?;XnQTe^SjZjBp7Xe2pb&$K~tDYdkV&-88AO(1Ss$j9nGY zPHeE41gtt>>N4z7A<eyMBu-nZ14lirBalJbcrXrdyv5ZVAV^<FbSmIp_Y++A^o^vx z*m(T-@yCAbs4p7t@MSvs=`guEsXoQTZyzqi<k5%|HwFdZ;GiOodLlwZv}*JuSIfq0 zcabqF=yIGdi#BVl>gcivDJHAT%cvA~r)jk5%d6h;>w926QE;p5*N7$eba;s$t~<FZ zw5b9CsHgFU)iAM?`yzDqQW%_?G0~as#Z<6dKKL7?%MfAblQEV7*L)lx?FnO6jSt1E z{;m!IATei%NLZJ2Sx#$AXOlb+BdExzaByBz;|VUUkAP3uBv>MiCqi&DGc&9(%M(^h zTNEGZ7dBHbx+MrXg*5R&BOPkda$)WmEkb5b-EqC+HezJA_&6D2KobzAdg?w%BQJ9) zz2@9pwL$yOyreFHJj*y|k>yZ{Mo;K<l&vOOS6v;0u)ta)2!`2^Bwb?xNaM^R5Q;UP zlRzwxup*TU){d&`rM5m`EMy<r-(YLSatx-w9YGNZHjMO)V6hoPVsxCTfO-a1d`-Fu z<#!>*#`=Ub6&$o4ELd{1EWtRgfLS4zuHu-n%7CJ9vqXLT0Vek(>@{pBy-4RIv_)=M ze9y~fx$`=d-88RJmQ&{JFx_eRlT)Utjnk#mGmQZ+DjiAWtp&q)z%|B2hk||~!)rA& zPBM-37-oKfD3_#JAz42t;<^b*0cVSpxjqky!a6Y_v~!ed>sT{qf(sXcz2RCYDW8*e zPDDJMjqC@72A**+zym1EOrI1BsB=WJ#8glpa3Br5Sqb-&2#o<IrsyeW8mF3DGzlAy z3Q3>s7a*_>JNKcH&C}cSCbYA1h|3}Or^|tMa0**$BMN4V%43$Nf~bSYg&r;}wM?$P zp@Iubo04(fhU1X{T+Z?iN^_lNdWoktgj0YpOn26zzuRzMZ#AMCphS$2AiP-YU!q`{ z2b$<}U660|4r1tq=ORg5r^$t2^kG$BmgT-?1BNq0!HJt+tMOTrxFwfHJ32O?G(6V_ z2nw+S4w2m`+JU_5CNR5TsL!MaIx6)_TAva)TS9R&F%t*LfvX_WaCwZa{$#El8_)Dq zO1E5suqK@bv@Wr!)yZT@CgzA~u7E8i!C<p}{N&wt-g{Fac$4U7TEwHV^sEcB;|Ety z^!8QXJ{2>T&RCRw`R!SvO?bqJUxWKw4XGTQNa4_*LOPAnW)aDUa6AY`hU!TJYPF~k z<0L(0TN;xJ+PH)S<y1)Kk~yZu_U!U<|Ic6i>XYNE7Z0xWO&O1zODc+MQ$E>cs?YZ2 zkAFnYc=RrF<b$%-EfEToT)at^p8;2Cf=Zxaz(OXZt7b=Z8vE>~l=h7qL)7^gaSY>j zp(Wb1MHn-CQpZeD0kl7AY=pk&dj0jc|KYFS`T1Xd^wCf7tv|o>16xKFq+dEX&$h>l z^M{UVc#Us!pUNuqtjb28N!xwkBQa0q#UVB#mYt52eo<2bd=A7?ixVyW!0Psp3#kpN z3x(Mw5wLt%GP^tLD;TuS!;2tO{cKd*<{MxC;KzS<|MDmJ)+A><j^fhQwEx4q-+Om^ z_Jf1ojh)tFlq~Cw<p@GZsB#Uem9H3takgNmL0Q(Bl2a-nD<1OnHGnLL4?BYM<w2qI ziU-;?HI;2gDkqNaISt(u+m??h#sAtjpS|&|J7ji%dk@kbuQs}-r^<`iDaw3>)+}Cl zH{T!_%}@cH@bo0Q@?JZ?5{NmcM7twI6@ixfjJ95u^8a(zPVXJdhc+*tIm-<miKh30 zXFdg1*~)`oXaK1zM&gx%B+}|zjkU5TW20=3$!bi_lGuw5OelutA;%AHIrX^t*RL(C z*36=IV;tCOo==(<pB`i6Ub!VdA)0f56AI#35Rjv~gK&*nHmf5jZ&qEtQ9~CYERgbQ z)@2}oTr{N+P6@}MhgrzMt}5V>*!0SxPegaRsK*RnE*1c_Bv2{XL_!Lg&<a|pdntkx z9J|DBL@Gjd*g3mKO?_NiFJ%&CAbbwGnc~AY?WiKR1#X0hH%p|A!?HuCu=T2HV~sVG zPBJMt)g2YReszBJ^ttZmOu2jZY<KhYFmB)p1gIl&3^t4#eg}odl@79@q-`agFK`<8 zPhE7LJ$uSmx`dxXRfx7NC)Q_-iJCjoA<a6)n5`x7{BCEPQ=zwz%c2iBsgd1LJ;kle z4#&@a?$f{at1t1dGtTIq{`TI#g0HW7R5<;PflG;Ai<d`FRqX?ERg}ziu@{oMNx-i} zw7e>hZcBL);HAJ^ibVg0<#6@v<}d%`%ddXrxqb<(p9m0BHKKH_9d1i?oDiw4tOBv} zYqQ#wY@4a?%@j3(yXIV4oJ4jJsH47p`TVc{%)<u{G}aG<w9)iZ!WoBBE;ZH)XQ0az zcYB%;3jxrwGkHIymb3(LsMZxgG8q$qZ8X-uYI);--+lPXlb`#o{cB&}UcJvhMOb%^ zIVy)W^*Q#=U3bt_IYv#f4@XDU7{{n!3ZcN9wA4-{Iml@9?)&e(^Zxf8nbwibE~Bu? zmZa07p**au@G6d%**Qy2^4d5KCqCfv32g>s<s0zscB6Zvo9pBD(X&tg#>Kb4vQv%D z^h)3U@UFiXw^sTBbOMpZeWMo;v1`b#R61xk_>LbsP4)0@P(+*}I_Q4(Ajgc^X@oj^ z)O>G-*sAUYlGGZXTUEabT4;RHTt9JoNgWzWoovr_PG4U<;$GV!+m!18iQY-p_N$n; z8p1>lczUpT#maSZp;8V29UU>)C0+AtD5+;wve7Ka%y<&>paZ2c0vd{O8O5{Zz#|Vg zA!we{(BXtp(dvZV9P|&`v?LI^9_23H39f4-g?OrEFt93+C3N!DsJNapMuL!(S)H5D z(Tu{HUMTMa0Wr~x>$k?Rr<@sa9Y+YHR9SQ;2^mApSRg~qfgt4YV=EXJDMpMzRfi!p z>Z#_II5u8JhId%uQfp_7=QRjws}L8`Lpn^lfikb*F1;B&6^P46jSI%rlSrE<wUR?! zx5y_Jc2Z162$0MGNuh@m(8Mqz=C{S|7ywr;dTk*<IPRn|qllVQRae#+^TKEsJ}`@z zk2`E(M-hf`fn$R~NElW_RxTwWA9ScaWEwJrm>X=7-CL+wX*d8+QCu_4eHDWfjSu!< z2dt-l7wjUEtTeFL4Gb<oRVU^Fz(9GR!v>cYl`PnJ6is~{V<}i{)NI>A9MhObZ2;>| zB=(nus_5FI31mUayvS)}AC@aUT_|W!o1b$;=SoD8SQ}XThC(N;lf+Y5gV!(uC~)ZT z?4vvwV+-4r$_H7hVI&8cs2u!=6S*XMM9UPJp=}WXGe5b1^=lJT-a#PuRls!2W~fe@ zDR1kkk*lR+PMwO~3?ah($X1zBjx-HK00oN+O7}dxI=W(Hj!+UVaD0}Rl`CWK?Cg12 zIN&M;vDh|9hPd<($t@D~fJ$p{N@rN$WgH@xlm#KAPSqSThF^i`fW9!`8qZSadf^OQ z&3Zkmlx*mrOkie;Y=6FW#)o~2s|x7yNnZB_ln0Mu$TcbEj!tFAQ$o$TPaavbu^_rg z8)QeKiN73eN>2V;Gz!M=t>jp42~9uGVOLNSUYLj|AyXr!WHuea1<X*ALv0)ZWRgH# zpdfrjGIMd8BSa~;NlZkAHXHqlT<=C4B+mR`AGj)28%hF4=SY+m5Czfjp*_c?d1@i0 zQ5$+(=uda?Z@%%t-~7ebuCAVtdqU^VLtv(qq*$G2LdaD}N)3zwEAEUnB6RQ(;ux7i z_Q(DDXR-bn0ukXv9k~&lC)g2#otajvczdJYXXo}ATK%zNa-<o(#-T7B)C{VbYJHiv z&NdD)F_b;@ZK+-I_8V7U`m5WI{`A%E!Hw?6{MwpWG4)72Mrj#P0gH*WG(Ir(R>TKb zALwkspfYGi?GRh09wV!mMk5l{_Uz?PUB2|g7cdYH*QW5gI=%|P6gCYUNGfMp6$qP? zknICJ($WloHjzHudA@t^TMs|_>1X?wZukn?C%ND@2pRhuq6it$yiL@XD#BWN;>g`& zJg1~K3a&0YOjMgnD|?AxYikwOF5Y|d+4&`>oZbm-_!%i%rwoaOVb5_-uxtiaSMw^+ z$Q^1F{5d<HBf}vg7D7#f>*vR}zOjAu$+PW+zj>+ZnBuf3MbK{Q2uX#iVMX~uL*u6P zy@e+;yTuWdB_NHnvnOvoy?SS#4_0{b)kAW_=w>4b?H+QpP(7b0)=aF#C?Pw{XX-d0 z#H7%gat|jmonP<2u6qSvJAfCE0WF8nMxvf+oZeM<9*!cwXO<Ilr8Nq;j-yJ9;ApZF zo$i@b5B={S`^!sNNW@1=cbZqn4$G@8U6_1m4?Z%-z&6yA67V;(7(=!YCzU?U(*N3_ ze0_qY9;4o{CRs))N@D9+!hA2@!0+~ny$GEA$?its#ZFzARpZkD@0u{4xa+JHsEnse z$+?W@6bm)<X@yG+3#qFW6!n2oKhOrvjON4Q29f_zhF)3?CT1>PO9u|4v>5d)N5UKa z8W3tn4yr2DhNaDv5RwKCAw!Tb9RlU)m-&pDSEClyxbn#`)oGzTYh5yfSgrUo@vMaN zP`ag5oXIhD*@n|ba;)NaNJ}2J2{b`82CJl6zM6(J!v-V-LgZED_8tCCj%yHi>~~s& zCH6u=HDzOgV&%(P^j-#W_sdDgL$pqz1?|rll=~47^Ww2egbbo2msy8p_Sn=g2RrgE zCgpZtiw@h^ISXELpA#fH?t?Hl+Y7qNK4e`eFTN*on<gA#goweSAb34ad~lWvqDPDC zh!}8H#-!!LTMi%hg)uI9*Md}wEb(|vh)oNNg{_tl5_1+o0RQI*_^!|x-kC&{-qwPq zz^#!qv|_Q-nzf!IBKx46^++zVfDw!vYW@NrXh_mnbWHW=)+mc+C!l>Ms`An{^C+^( zwV<$N2PwiT`?Pt%$gbMsiN51?alA{~V@Z;(Y>9)e<rRO}vSHAU#2C|N{F!<ll?9Qi zmyC_~0#!uTls22NsfLSFTW$*ORR?VL*z_Lft1)r(T!T55v8RiqapoJ7F>qp<Ay|aN zJA5(q=zcDa!~~~+!8c4K(KzRDX-ypfeM237e*O}vSC*ks_9n27o^$1eDbIT~mFB~n z2yENg!?CuxW7=Q5mQh17gUWvl9g`)}2%M+}dq||0ys460F%Dz`6S5riNb~xYD_;7U znlP(%v*#v@FGpjV^5w#%izLx8#v-Sjqb#DKXEB>Ru9M7=Ir{d6bcE$e(1;s@Q<~=l zFUUYeWlj&f+;`>7MKJ%n<Wbso!#P&3&7wbf(I|-G<HkS9$EC-8=2eOixz#)q%aUPf z2i)^z)tR5z#vrW4?MPxcz-G7YLOq9V8MbNLuF&hBg}Z`U#fW31ab@i}S3N)HPL-ZD z(OVMifhUE`><sZBTI7h)bsH|%BDg#(5KkfVj2D(NQA1IIO41<#Hfi9t1=&1fjKDx7 z-E7pP(&xX&$(pB3!vuS`J_**moN@L~!tJnzc_NZ{Y%$1AIUFAr&`}ioOIP=wGEle< z+c+q&upcQsS<I>oA$55N=@{IB*R*|@c|i0sKT`0C#0h(vM{SZ}l0m1vdbOn!U8Q#o z$2^gY6S8aP+`Mmy&0$kNwJJs;q-fO6bn)z=rSad9W8M_Tn6%N$HY#V*6hVH(Dcg2* zvch3-0tsVK4!n4zvUT!f8-%>UHXQ-BbRO@iyk+LOJG0E$)|Pfn=q?*Rk>G8FU?FsO zeV}mU8DQ8ov8Fqvl2eSN%pHRmtM6QMbgHq%qg&GVR<;i#TT8XbfbVGTwL~?=gy0z| z!w^0@%7O$Day%}jt8gSybHzf;oRAP%r+Ik^fL@BGnnJ3Km48TXlv5i#7#VGG#+|`n zsaSa(6)ZC;Z4$2oN*F|F(lCQ#lm%`(i$J=v96Zh;)T;m(xgblLqFe$kYpV_MD8c53 z4JFWFBrphM-T$$Gh84C$l2bd9w8Tpo6#-!wW((U_oJ2fy4nSQ&YZ~?9wQ^ixxb_KN z)=VoJ6lUTDWb6+`)Ow3;hnmes8(JK*KEbzdV;m{^fd3EA_2)`NL?Lwm0000<MNUMn GLSTZ8Rz(8< literal 0 HcmV?d00001 diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index db214a81..d303b777 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -5,3 +5,9 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +/** A failure's message for showing it: the error's own, else the value as text. */ +export const messageOf = (cause: unknown) => + typeof cause === "object" && cause !== null && "message" in cause && typeof cause.message === "string" + ? cause.message + : String(cause); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 7b6d3bce..d370e460 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as ShellRouteImport } from './routes/_shell' import { Route as ShellSettingsRouteImport } from './routes/_shell.settings' import { Route as ShellSettingsIndexRouteImport } from './routes/_shell.settings.index' +import { Route as ShellSettingsServersRouteImport } from './routes/_shell.settings.servers' import { Route as ShellSettingsThemesRouteImport } from './routes/_shell.settings.themes' import { Route as ShellSettingsWorkspacesRouteImport } from './routes/_shell.settings.workspaces' import { Route as ShellWorkspacesWorkspaceIdRouteImport } from './routes/_shell.workspaces.$workspaceId' @@ -36,6 +37,11 @@ const ShellSettingsIndexRoute = ShellSettingsIndexRouteImport.update({ path: '/', getParentRoute: () => ShellSettingsRoute, } as any) +const ShellSettingsServersRoute = ShellSettingsServersRouteImport.update({ + id: '/servers', + path: '/servers', + getParentRoute: () => ShellSettingsRoute, +} as any) const ShellSettingsThemesRoute = ShellSettingsThemesRouteImport.update({ id: '/themes', path: '/themes', @@ -56,6 +62,7 @@ const ShellWorkspacesWorkspaceIdRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute '/settings': typeof ShellSettingsRouteWithChildren + '/settings/servers': typeof ShellSettingsServersRoute '/settings/themes': typeof ShellSettingsThemesRoute '/settings/workspaces': typeof ShellSettingsWorkspacesRoute '/workspaces/$workspaceId': typeof ShellWorkspacesWorkspaceIdRoute @@ -63,6 +70,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/settings/servers': typeof ShellSettingsServersRoute '/settings/themes': typeof ShellSettingsThemesRoute '/settings/workspaces': typeof ShellSettingsWorkspacesRoute '/workspaces/$workspaceId': typeof ShellWorkspacesWorkspaceIdRoute @@ -73,6 +81,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/_shell': typeof ShellRouteWithChildren '/_shell/settings': typeof ShellSettingsRouteWithChildren + '/_shell/settings/servers': typeof ShellSettingsServersRoute '/_shell/settings/themes': typeof ShellSettingsThemesRoute '/_shell/settings/workspaces': typeof ShellSettingsWorkspacesRoute '/_shell/workspaces/$workspaceId': typeof ShellWorkspacesWorkspaceIdRoute @@ -83,6 +92,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/settings' + | '/settings/servers' | '/settings/themes' | '/settings/workspaces' | '/workspaces/$workspaceId' @@ -90,6 +100,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/settings/servers' | '/settings/themes' | '/settings/workspaces' | '/workspaces/$workspaceId' @@ -99,6 +110,7 @@ export interface FileRouteTypes { | '/' | '/_shell' | '/_shell/settings' + | '/_shell/settings/servers' | '/_shell/settings/themes' | '/_shell/settings/workspaces' | '/_shell/workspaces/$workspaceId' @@ -140,6 +152,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ShellSettingsIndexRouteImport parentRoute: typeof ShellSettingsRoute } + '/_shell/settings/servers': { + id: '/_shell/settings/servers' + path: '/servers' + fullPath: '/settings/servers' + preLoaderRoute: typeof ShellSettingsServersRouteImport + parentRoute: typeof ShellSettingsRoute + } '/_shell/settings/themes': { id: '/_shell/settings/themes' path: '/themes' @@ -165,12 +184,14 @@ declare module '@tanstack/react-router' { } interface ShellSettingsRouteChildren { + ShellSettingsServersRoute: typeof ShellSettingsServersRoute ShellSettingsThemesRoute: typeof ShellSettingsThemesRoute ShellSettingsWorkspacesRoute: typeof ShellSettingsWorkspacesRoute ShellSettingsIndexRoute: typeof ShellSettingsIndexRoute } const ShellSettingsRouteChildren: ShellSettingsRouteChildren = { + ShellSettingsServersRoute: ShellSettingsServersRoute, ShellSettingsThemesRoute: ShellSettingsThemesRoute, ShellSettingsWorkspacesRoute: ShellSettingsWorkspacesRoute, ShellSettingsIndexRoute: ShellSettingsIndexRoute, diff --git a/apps/web/src/routes/_shell.settings.servers.tsx b/apps/web/src/routes/_shell.settings.servers.tsx new file mode 100644 index 00000000..123758e4 --- /dev/null +++ b/apps/web/src/routes/_shell.settings.servers.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ServersSettings } from "../settings/servers/servers"; + +export const Route = createFileRoute("/_shell/settings/servers")({ component: ServersSettings }); diff --git a/apps/web/src/settings/blocks.tsx b/apps/web/src/settings/blocks.tsx index 8ee1bd47..5fb5fdcb 100644 --- a/apps/web/src/settings/blocks.tsx +++ b/apps/web/src/settings/blocks.tsx @@ -1,4 +1,6 @@ -import type { ReactNode } from "react"; +import { type ReactNode, useState } from "react"; + +import { Input } from "@/components/ui/input"; /** Building blocks every settings section is made of: a page of titled sections of row groups. */ @@ -67,3 +69,55 @@ export function Keys({ children }: { children: ReactNode }) { </kbd> ); } + +/** A field that saves itself: Enter or leaving it passes on a changed value, trimmed; Escape puts the saved one back. */ +export function SavingInput({ + value, + label, + placeholder, + autoFocus = false, + className, + onCommit, +}: { + value: string; + label: string; + placeholder?: string; + autoFocus?: boolean; + className?: string; + onCommit: (name: string) => void; +}) { + const [draft, setDraft] = useState<string | null>(null); + // Reads the field itself: Escape resets it just before blurring, sooner than state would. + const commit = (next: string) => { + const trimmed = next.trim(); + setDraft(null); + if (trimmed !== value) onCommit(trimmed); + }; + return ( + <Input + aria-label={label} + value={draft ?? value} + placeholder={placeholder} + autoFocus={autoFocus} + onFocus={(event) => autoFocus && event.currentTarget.select()} + className={className} + onChange={(event) => setDraft(event.target.value)} + onBlur={(event) => commit(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + event.currentTarget.value = value; + event.currentTarget.blur(); + } + }} + /> + ); +} + +/** The quiet look of a settings field on every theme; the stock input fills in dark ones. */ +export const quietField = "rounded-lg border-edge bg-ink/3 shadow-none dark:bg-ink/3"; + +/** A name as it is typed in a shell: a variable, a command or a flag. */ +export const Code = ({ children }: { children: ReactNode }) => ( + <code className="rounded bg-ink/8 px-1 py-px font-mono text-[0.9em] text-ink">{children}</code> +); diff --git a/apps/web/src/settings/sections.ts b/apps/web/src/settings/sections.ts index 50d30a71..a5b11186 100644 --- a/apps/web/src/settings/sections.ts +++ b/apps/web/src/settings/sections.ts @@ -1,7 +1,7 @@ -import { Layers, type LucideIcon, Palette } from "lucide-react"; +import { Layers, type LucideIcon, Palette, Server } from "lucide-react"; export interface SettingsSection { - readonly to: "/settings/workspaces" | "/settings/themes"; + readonly to: "/settings/workspaces" | "/settings/themes" | "/settings/servers"; readonly label: string; readonly icon: LucideIcon; /** Sidebar heading the section sits under. */ @@ -25,6 +25,13 @@ export const sections: readonly SettingsSection[] = [ group: "App", keywords: ["appearance", "colors", "dark", "light"], }, + { + to: "/settings/servers", + label: "Servers", + icon: Server, + group: "App", + keywords: ["local", "remote", "cli", "service", "install", "codework", "version", "rpc"], + }, ]; /** Sections whose label or keywords contain every word of the query, in sidebar order. */ diff --git a/apps/web/src/settings/servers/local.tsx b/apps/web/src/settings/servers/local.tsx new file mode 100644 index 00000000..a3544254 --- /dev/null +++ b/apps/web/src/settings/servers/local.tsx @@ -0,0 +1,350 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import type { CliAction, CliConfig, CliStatus } from "@codeworksh/server/contract"; +import { Check, Copy, TriangleAlert, Undo2 } from "lucide-react"; +import { Option } from "effect"; +import { AsyncResult } from "effect/reactivity"; +import { type ReactNode, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { messageOf } from "@/lib/utils"; +import brand from "../../assets/codework.png"; +import { Server } from "../../kernel/rpc"; +import { Code, quietField as field, SavingInput } from "../blocks"; +import { Variables } from "./variables"; + +export const cliKey = ["cli"]; +export const statusAtom = Server.query("cli.status", undefined, { reactivityKeys: cliKey }); +const serviceAtom = Server.mutation("cli.service"); +const configureAtom = Server.mutation("cli.configure"); + +// The name is the app's own, kept in its kv store under the built-in package. +const nameKey = { package: "codework", key: "servers.local.name" } as const; +const nameKeys = ["servers.local.name"]; +const nameAtom = Server.query("kv.get", nameKey, { reactivityKeys: nameKeys }); +const saveNameAtom = Server.mutation("kv.set"); + +/** The local server's name: "CodeWork" until renamed. */ +export function useLocalName() { + const result = useAtomValue(nameAtom); + const saved = AsyncResult.isSuccess(result) ? Option.getOrUndefined(result.value) : undefined; + return typeof saved === "string" ? saved : "CodeWork"; +} + +const INSTALL = "curl -fsSL https://codework.sh/install | sh"; + +type Tone = "success" | "warning" | "danger" | "info" | "muted"; + +const TONES: Record<Tone, string> = { + success: "bg-success/15 text-success", + warning: "bg-warning/15 text-warning", + danger: "bg-destructive/15 text-destructive", + info: "bg-info/15 text-info", + muted: "bg-ink/8 text-ink-muted", +}; + +/** A small coloured pill: a server's state, or a fixed fact such as "Default". */ +export const Badge = ({ tone, children }: { tone: Tone; children: ReactNode }) => ( + <span className={`shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium ${TONES[tone]}`}>{children}</span> +); + +const Mark = () => <img src={brand} alt="" className="size-5 shrink-0" />; + +const Version = ({ version }: { version: string }) => ( + <span className="font-mono text-[13px] text-ink-muted">v{version}</span> +); + +/** The local server's row in the list: always there, and the default. */ +export function LocalItem({ status, selected }: { status: CliStatus | undefined; selected: boolean }) { + const name = useLocalName(); + const cli = status?.cli; + // Only the binary is judged here; the server's own state belongs to the detail. + const problem = + cli === undefined ? undefined : cli === null ? "No codework found" : "failure" in cli ? cli.failure : undefined; + return ( + <div className={`flex items-start gap-3 px-5 py-4 ${selected ? "bg-ink/4" : ""}`}> + <span className="pt-0.5"> + <Mark /> + </span> + <div className="flex min-w-0 flex-1 flex-col gap-2"> + <span className="flex flex-wrap items-center gap-2"> + <span className="truncate font-medium">{name}</span> + {cli != null && "version" in cli && <Version version={cli.version} />} + {problem !== undefined && ( + <TriangleAlert className="size-3.5 shrink-0 text-warning" aria-label={problem}> + <title>{problem} + + )} + + + Local + Default + +
+
+ ); +} + +/** The local server: its name, its state and controls, then the executable that runs it and its home. */ +export function LocalDetail({ status }: { status: CliStatus }) { + const service = useAtomSet(serviceAtom, { mode: "promise" }); + const saveName = useAtomSet(saveNameAtom, { mode: "promise" }); + const saveConfig = useAtomSet(configureAtom, { mode: "promise" }); + const name = useLocalName(); + const [running, setRunning] = useState(null); + const [error, setError] = useState(null); + const [nameError, setNameError] = useState(null); + const { server, cli } = status; + const usable = cli !== null && "version" in cli; + + const configure = (config: typeof CliConfig.Type) => + saveConfig({ payload: config, reactivityKeys: cliKey }).then( + () => undefined, + (cause: unknown) => messageOf(cause), + ); + + const act = (action: CliAction) => { + setError(null); + setRunning(action); + service({ payload: { action }, reactivityKeys: cliKey }) + .catch((cause: unknown) => setError(messageOf(cause))) + .finally(() => setRunning(null)); + }; + + return ( +
+
+ + {name} + Local +
+ + + + { + // The server keeps its name when the field is cleared. + if (!next) return; + setNameError(null); + saveName({ payload: { ...nameKey, value: next }, reactivityKeys: nameKeys }).catch( + (cause: unknown) => setNameError(messageOf(cause)), + ); + }} + /> + {nameError !== null && {nameError}} + + + + +
+
+ Server + + + +
+ {usable && server._tag === "Stopped" && ( + + )} + {usable && server._tag !== "Stopped" && ( +
+ + +
+ )} +
+ {error !== null &&

{error}

} +
+ +
+

Runtime

+ + configure({ executable: next || null })} + > + {cli === null && } + {cli !== null && "failure" in cli && ( + {cli.failure} + )} + + + CODEWORK_HOME_DIR path + + } + label="CODEWORK_HOME_DIR path" + description="codework home and config directory." + value={status.home.custom ? status.home.path : ""} + placeholder={status.home.fallback} + onSave={(next) => configure({ home: next || null })} + /> + + Additional CLI arguments passed to codework service start. + + } + value={status.args} + placeholder="--port 7433" + onSave={(next) => configure({ args: next })} + /> + +
+ +
+

Environment

+ + + configure({ env })} /> + + +
+
+ ); +} + +const Card = ({ children }: { children: ReactNode }) => ( +
{children}
+); + +/** A titled block whose value sits under its description, as in a form. */ +function Field({ title, description, children }: { title: ReactNode; description: ReactNode; children?: ReactNode }) { + return ( +
+ {title} + {description} +
{children}
+
+ ); +} + +/** + * A setting the user may change: the field saves when left; blank, or Reset while it is set, goes + * back to the default its placeholder shows. Its error, from saving, shows under it until the next save. + */ +function SettingField({ + title, + label, + description, + value, + placeholder, + onSave, + children, +}: { + title: ReactNode; + /** The field's name for screen readers, when the title is more than text. */ + label: string; + description: ReactNode; + value: string; + placeholder: string; + onSave: (value: string) => Promise; + children?: ReactNode; +}) { + const [saving, setSaving] = useState(false); + const [error, setError] = useState(undefined); + const save = (next: string) => { + setSaving(true); + setError(undefined); + void onSave(next).then((failure) => { + setError(failure); + setSaving(false); + }); + }; + return ( + + + + {value !== "" && ( + + )} + + {error !== undefined && {error}} + {children} + + ); +} + +function ServerLine({ status }: { status: CliStatus }) { + const { server } = status; + if (server._tag === "Running") + return ( + <> + Running at {server.url} · pid {server.pid} + + ); + if (server._tag === "Incompatible") + return ( + <> + {server.url} · pid {server.pid} answers, but not in the form this app + expects: it is another build. Update the CLI, or run a server built with this app. + + ); + if (server._tag === "Unreachable") + return ( + <> + Registered at {server.url} (pid {server.pid}) but not answering this app. + + ); + return "Not running. Once started, it keeps running in the background after the app quits."; +} + +function CopyCommand({ command }: { command: string }) { + const [copied, setCopied] = useState(false); + return ( + + {command} + + + ); +} diff --git a/apps/web/src/settings/servers/servers.tsx b/apps/web/src/settings/servers/servers.tsx new file mode 100644 index 00000000..a7aa182a --- /dev/null +++ b/apps/web/src/settings/servers/servers.tsx @@ -0,0 +1,72 @@ +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import { RefreshCw } from "lucide-react"; +import { AsyncResult } from "effect/reactivity"; +import { useEffect, useState } from "react"; + +import { Skeleton } from "@/components/ui/skeleton"; +import { LocalDetail, LocalItem, statusAtom } from "./local"; + +/** The CodeWork servers the app can work with, listed beside the one selected; the local one is always there. */ +export function ServersSettings() { + const result = useAtomValue(statusAtom); + const refresh = useAtomRefresh(statusAtom); + const status = AsyncResult.isSuccess(result) ? result.value : undefined; + + return ( + // The pane is narrower than the window by the settings sidebar, so the layout follows the pane. The + // page fills it and does not scroll: side by side only the detail does, stacked the panel does. +
+
+

Servers

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

The app server did not answer.

+ ) : ( +
+ + + +
+ )} +
+
+
+ ); +} + +/** "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()); + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + setSince(Date.now()); + setNow(Date.now()); + }, [at]); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 30_000); + return () => clearInterval(timer); + }, []); + const minutes = Math.floor((now - since) / 60_000); + if (minutes < 1) return "Checked just now"; + return minutes < 60 ? `Checked ${minutes}m ago` : `Checked ${Math.floor(minutes / 60)}h ago`; +} diff --git a/apps/web/src/settings/servers/variables.tsx b/apps/web/src/settings/servers/variables.tsx new file mode 100644 index 00000000..1bc23930 --- /dev/null +++ b/apps/web/src/settings/servers/variables.tsx @@ -0,0 +1,133 @@ +import type { CliEnvVar } from "@codeworksh/server/contract"; +import { Lock, LockOpen, Plus, X } from "lucide-react"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { quietField } from "../blocks"; + +interface Row { + readonly id: number; + readonly name: string; + readonly value: string; + readonly sensitive: boolean; + /** A sensitive value saved earlier, which the app never gets back: kept unless typed over. */ + readonly kept: boolean; +} + +let nextId = 0; +const rowOf = ({ name, value, sensitive }: CliEnvVar): Row => ({ + id: nextId++, + name, + value: value ?? "", + sensitive, + kept: value === undefined, +}); + +/** What saving sends: named rows only, and a kept sensitive value as no value, so it stays. */ +const toSave = (rows: readonly Row[]): CliEnvVar[] => + rows + .filter((row) => row.name.trim() !== "") + .map(({ name, value, sensitive, kept }) => + kept && value === "" ? { name: name.trim(), sensitive } : { name: name.trim(), sensitive, value }, + ); + +/** + * The variables the server starts with, one `NAME = value` row each. A row saves when its field is + * left, its lock is toggled or it is removed; a new row waits for a name. + */ +export function Variables({ + saved, + onSave, +}: { + saved: readonly CliEnvVar[]; + onSave: (env: CliEnvVar[]) => Promise; +}) { + const [rows, setRows] = useState(() => saved.map(rowOf)); + const [error, setError] = useState(undefined); + + const save = (next: readonly Row[]) => { + const env = toSave(next); + if (JSON.stringify(env) === JSON.stringify(toSave(saved.map(rowOf)))) return; + setError(undefined); + void onSave(env).then(setError); + }; + const update = (id: number, patch: Partial) => + setRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row))); + + return ( +
+ + {rows.map((row, index) => ( + + update(row.id, { name: event.target.value })} + onBlur={() => save(rows)} + /> + = + update(row.id, { value: event.target.value })} + onBlur={() => save(rows)} + /> + + + + ))} + {rows.some((row) => row.sensitive) && ( + + Sensitive values stay with the app server and are never shown here again. + + )} + {error !== undefined && {error}} +
+ ); +} diff --git a/apps/web/src/settings/workspaces.tsx b/apps/web/src/settings/workspaces.tsx index 7bf30756..2e080add 100644 --- a/apps/web/src/settings/workspaces.tsx +++ b/apps/web/src/settings/workspaces.tsx @@ -14,8 +14,8 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { messageOf } from "@/lib/utils"; import { isMac } from "@codeworksh/widget/view"; import { iconOf, icons } from "../shell/icons"; import { shortcutLabel } from "../shell/switcher"; @@ -31,7 +31,7 @@ import { workspacesKey, workspaceTag, } from "../wm/workspaces"; -import { Group, Keys, Page, Row, Section } from "./blocks"; +import { Group, Keys, SavingInput, Page, Row, Section } from "./blocks"; const sameOrder = (ids: readonly string[], workspaces: readonly Workspace[]) => ids.length === workspaces.length && ids.every((id, index) => workspaces[index]?.id === id); @@ -54,7 +54,7 @@ export function WorkspacesSettings() { const run = (action: () => Promise) => { setError(null); - action().catch((cause: unknown) => setError(describe(cause))); + action().catch((cause: unknown) => setError(messageOf(cause))); }; const addWorkspace = () => @@ -160,31 +160,19 @@ interface RowProps { /** Renames in place: commits on Enter or blur, Escape restores the saved name. */ function NameField({ workspace, autoFocus, onError }: RowProps & { readonly autoFocus: boolean }) { const save = useAtomSet(updateWorkspace, { mode: "promise" }); - const [draft, setDraft] = useState(null); - // Reads the field itself: Escape resets it just before blurring, sooner than state would. - const commit = (value: string) => { - const name = value.trim(); - setDraft(null); - if (!name || name === workspace.name) return; - save({ payload: { id: workspace.id, name }, reactivityKeys: workspacesKey }).catch((cause: unknown) => - onError(describe(cause)), - ); - }; return ( - autoFocus && event.currentTarget.select()} + // Reads as plain text in the row until hovered or focused. className="h-8 min-w-24 flex-1 border-transparent bg-transparent shadow-none hover:border-input focus-visible:border-input dark:bg-transparent" - onChange={(event) => setDraft(event.target.value)} - onBlur={(event) => commit(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === "Enter") event.currentTarget.blur(); - if (event.key === "Escape") { - event.currentTarget.value = workspace.name; - event.currentTarget.blur(); - } + onCommit={(name) => { + // A workspace keeps its name when the field is cleared. + if (!name) return; + save({ payload: { id: workspace.id, name }, reactivityKeys: workspacesKey }).catch((cause: unknown) => + onError(messageOf(cause)), + ); }} /> ); @@ -214,7 +202,7 @@ function IconPicker({ workspace, onError }: RowProps) { setOpen(false); if (name === workspace.icon) return; save({ payload: { id: workspace.id, icon: name }, reactivityKeys: workspacesKey }).catch( - (cause: unknown) => onError(describe(cause)), + (cause: unknown) => onError(messageOf(cause)), ); }} > @@ -262,7 +250,7 @@ function DeleteButton({ workspace, onError }: RowProps) { onClick={() => // The server drops the workspace's widgets, so the desk reloads with the list. remove({ payload: { id: workspace.id }, reactivityKeys: [...workspacesKey, ...deskKey] }).catch( - (cause: unknown) => onError(describe(cause)), + (cause: unknown) => onError(messageOf(cause)), ) } > @@ -273,8 +261,3 @@ function DeleteButton({ workspace, onError }: RowProps) { ); } - -const describe = (cause: unknown) => - typeof cause === "object" && cause !== null && "message" in cause && typeof cause.message === "string" - ? cause.message - : String(cause); diff --git a/apps/web/src/style.css b/apps/web/src/style.css index ff1d02f2..fcc845d5 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -23,6 +23,8 @@ --cw-scrollbar: color-mix(in oklab, var(--t-text) 16%, transparent); --cw-danger: light-dark(#dc2626, #f87171); --cw-warning: light-dark(#d97706, #fbbf24); + --cw-success: light-dark(#16a34a, #4ade80); + --cw-info: light-dark(#2563eb, #60a5fa); --cw-highlight: light-dark(rgb(255 255 255 / 0.9), color-mix(in oklab, var(--t-text) 9%, transparent)); --cw-shadow-near: light-dark(rgb(0 0 0 / 0.06), rgb(0 0 0 / 0.35)); --cw-shadow-far: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.5)); @@ -68,6 +70,8 @@ --color-accent-foreground: var(--cw-ink); --color-destructive: var(--cw-danger); --color-warning: var(--cw-warning); + --color-success: var(--cw-success); + --color-info: var(--cw-info); --color-border: var(--cw-edge); --color-input: var(--cw-edge-focus); --color-ring: color-mix(in oklab, var(--cw-brand) 50%, transparent); From 989359a35e96fc4332823d2d45c880a12ddd3e2a Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sun, 18 Oct 2026 01:28:47 +0530 Subject: [PATCH 17/20] =?UTF-8?q?feat(desktop):=20add=20local=20servers=20?= =?UTF-8?q?from=20Settings=20=E2=80=BA=20Servers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Servers now live in a `servers` table, with the built-in local server taking over the settings it had in kv. An Add server dialog in three steps (kind, identity, config) adds another local server with a home of its own and `--port 0`. Each server has its own executable, home, launch args and variables; a switch in the list picks the default, the one the app connects through, and the app reconnects to it at once. Added servers can be removed; the built-in one and the default stay. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli.ts | 252 +++++++++++----------- apps/server/src/codework.ts | 92 ++------ apps/server/src/contract.ts | 80 +++++-- apps/server/src/db.ts | 40 ++++ apps/server/src/server.ts | 8 +- apps/server/src/servers.ts | 233 ++++++++++++++++++++ apps/web/src/components/ui/switch.tsx | 24 +++ apps/web/src/settings/servers/add.tsx | 239 ++++++++++++++++++++ apps/web/src/settings/servers/local.tsx | 192 ++++++++++++----- apps/web/src/settings/servers/servers.tsx | 65 +++++- 10 files changed, 931 insertions(+), 294 deletions(-) create mode 100644 apps/server/src/servers.ts create mode 100644 apps/web/src/components/ui/switch.tsx create mode 100644 apps/web/src/settings/servers/add.tsx diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 074ecf4b..891760c7 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -1,4 +1,4 @@ -import { Config, Effect, FileSystem, Option, Schedule, Schema, Stream } from "effect"; +import { Config, Effect, FileSystem, Option, Schedule, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; /* @effect-diagnostics nodeBuiltinImport:off -- the install folder sits under the OS home directory. */ import os from "node:os"; @@ -6,68 +6,19 @@ import path from "node:path"; import { Codework } from "./codework.ts"; import { type CliAction, - type CliConfig, - type CliEnvVar, - CliError, type CliInstall, type CliServer, + type ServerAdd, + ServersError, + type ServerStatus, + type ServerUpdate, } from "./contract.ts"; -import { Kv } from "./kv.ts"; - -const executableKey = { package: "codework", key: "servers.local.executable" } as const; -const envKey = { package: "codework", key: "servers.local.env" } as const; -const argsKey = { package: "codework", key: "servers.local.args" } as const; - -const savedArgs = Effect.gen(function* () { - const saved = yield* (yield* Kv.Service).get(argsKey.package, argsKey.key); - return Option.getOrElse( - Option.filter(saved, (value): value is string => typeof value === "string"), - () => "", - ); -}); +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] ?? ""); -// Stored with every value; only the app server reads the sensitive ones. -const StoredEnv = Schema.Array(Schema.Struct({ name: Schema.String, value: Schema.String, sensitive: Schema.Boolean })); -type StoredEnv = typeof StoredEnv.Type; - -const savedEnv = Effect.gen(function* () { - const saved = yield* (yield* Kv.Service).get(envKey.package, envKey.key); - return Option.getOrElse(Option.flatMap(saved, Schema.decodeUnknownOption(StoredEnv)), (): StoredEnv => []); -}); - -const VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; - -/** - * The variables as chosen: names must be shell names, once each, and leave the home to its own - * setting; a sensitive one sent without a value keeps the saved value. - */ -const nextEnv = (chosen: ReadonlyArray, saved: StoredEnv) => - Effect.gen(function* () { - const names = new Set(); - const next: Array = []; - for (const { name: raw, sensitive, value } of chosen) { - const name = raw.trim(); - if (!VARIABLE_NAME.test(name)) - return yield* new CliError({ - message: `${name || "A blank name"} is not a variable name: use letters, digits and _.`, - }); - if (name === "CODEWORK_HOME_DIR") - return yield* new CliError({ message: "Set CODEWORK_HOME_DIR with the home path above." }); - if (names.has(name)) return yield* new CliError({ message: `${name} is set twice.` }); - names.add(name); - // Only a variable that stays sensitive keeps its saved value, so none is ever revealed by unmarking it. - const kept = sensitive - ? saved.find((variable) => variable.name === name && variable.sensitive)?.value - : undefined; - next.push({ name, sensitive, value: value ?? kept ?? "" }); - } - return next; - }); - // 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")), @@ -123,23 +74,17 @@ const probe = (file: string, custom: boolean) => Effect.catch((error) => Effect.succeed({ path: file, custom, failure: error.message })), ); -const chosenExecutable = Effect.gen(function* () { - const saved = yield* (yield* Kv.Service).get(executableKey.package, executableKey.key); - return Option.getOrUndefined(Option.filter(saved, (value): value is string => typeof value === "string")); -}); +const exists = (file: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(file).pipe(Effect.orElseSucceed(() => false)); + }); /** - * The `codework` the app uses: the one chosen in Settings; else the install script's, else the login - * shell's, whichever runs first; when neither runs, the first found, with why. + * The `codework` found without a choice: the install script's, else the login shell's, whichever + * runs first; when neither runs, the first found, with why. */ -const executable = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exists = (file: string) => fs.exists(file).pipe(Effect.orElseSucceed(() => false)); - const chosen = yield* chosenExecutable; - if (chosen !== undefined) - return (yield* exists(chosen)) - ? yield* probe(chosen, true) - : ({ path: chosen, custom: true, failure: "No file at this path." } satisfies CliInstall); +const found = Effect.gen(function* () { const candidates = [ path.join(yield* installDir, "codework"), ...Option.toArray(Option.fromUndefinedOr(yield* onPath)), @@ -147,24 +92,22 @@ const executable = Effect.gen(function* () { let broken: CliInstall | null = null; for (const file of candidates) { if (!(yield* exists(file))) continue; - const found = yield* probe(file, false); - if ("version" in found) return found; - broken ??= found; + const install = yield* probe(file, false); + if ("version" in install) return install; + broken ??= install; } return broken; }); -/** A path typed in Settings: `~` is the user's home; blank means none; anything else must be absolute. */ -const chosenPath = (input: string | null) => - Effect.gen(function* () { - const trimmed = input?.trim() ?? ""; - if (trimmed === "") return null; - const expanded = - trimmed === "~" || trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(1)) : trimmed; - if (!path.isAbsolute(expanded)) - return yield* new CliError({ message: "Use a full path, such as ~/.codework or /usr/local/bin/codework." }); - return path.normalize(expanded); - }); +/** 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 alive = (pid: number) => Effect.sync(() => { @@ -178,69 +121,114 @@ const alive = (pid: number) => }); /** - * The registered server, asked through the app's own connection to it. A build whose replies differ - * still answers `server.info`, so a call the app depends on decides whether it works with this app. + * 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 server = Effect.gen(function* () { - const registered = yield* Codework.registration; - if (Option.isNone(registered) || !(yield* alive(registered.value.pid))) return { _tag: "Stopped" } as const; - const { url, pid } = registered.value; - const info = yield* Codework.call((client) => client("server.info", {})).pipe(Effect.option); - if (Option.isNone(info) || info.value.pid !== pid) return { _tag: "Unreachable", url, pid } satisfies CliServer; - const { version } = info.value; - const usable = yield* Codework.call((client) => client("session.list", {})).pipe(Effect.isSuccess); - return { _tag: usable ? "Running" : "Incompatible", url, pid, version } satisfies CliServer; -}); +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 { 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); + if (Option.isNone(info) || info.value.pid !== pid) return { _tag: "Unreachable", url, pid } satisfies CliServer; + const { version } = info.value; + const usable = yield* Codework.call((client) => client("session.list", {})).pipe(Effect.isSuccess); + return { _tag: usable ? "Running" : "Incompatible", url, pid, version } satisfies CliServer; + }); // 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. -const awaitConnection = Codework.call((client) => client("server.info", {})).pipe( - Effect.retry(Schedule.spaced("250 millis")), - Effect.timeout("15 seconds"), - Effect.ignore, -); +// status read right after from calling a healthy server unreachable. Only when there is one to reach. +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")), + Effect.timeout("15 seconds"), + Effect.ignore, + ); +}); export const handlers = { - "cli.status": Effect.fn("cli.status")(function* () { - const [cli, registered] = yield* Effect.all([executable, server], { concurrency: "unbounded" }); - const env = (yield* savedEnv).map(({ name, sensitive, value }) => - sensitive ? { name, sensitive } : { name, sensitive, value }, + "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" }, ); - return { home: yield* (yield* Codework.Home).get, cli, server: registered, args: yield* savedArgs, env }; }), - "cli.configure": Effect.fn("cli.configure")(function* (config: typeof CliConfig.Type) { - if (config.executable !== undefined) { - const chosen = yield* chosenPath(config.executable); - yield* (yield* Kv.Service).set(executableKey.package, executableKey.key, chosen); - } - if (config.args !== undefined) yield* (yield* Kv.Service).set(argsKey.package, argsKey.key, config.args.trim()); - if (config.env !== undefined) { - const next = yield* nextEnv(config.env, yield* savedEnv); - yield* (yield* Kv.Service).set(envKey.package, envKey.key, next); - } - if (config.home !== undefined) { - yield* (yield* Codework.Home).set(yield* chosenPath(config.home)); - if (Option.isSome(yield* Codework.registration)) yield* awaitConnection; - } + "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 the server's lifecycle; the app only asks it, with the variables chosen and in the - // home the app uses, which a variable never overrides. - "cli.service": Effect.fn("cli.service")(function* ({ action }: { readonly action: CliAction }) { - const cli = yield* executable; + // 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 CliError({ message: "No working codework CLI was found." }); - const home = (yield* (yield* Codework.Home).get).path; - const env = Object.fromEntries((yield* savedEnv).map(({ name, value }) => [name, value])); + 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(yield* savedArgs); + const args = action === "stop" ? [] : splitArgs(row.args); const { output, code } = yield* run(cli.path, ["service", action, ...args], "30 seconds", { ...env, - CODEWORK_HOME_DIR: home, - }).pipe(Effect.mapError((error) => new CliError({ message: `codework service ${action}: ${error.message}` }))); - if (code !== 0) return yield* new CliError({ message: `codework service ${action} ${reason(output, code)}` }); - if (action !== "stop") yield* awaitConnection; + 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; }), }; diff --git a/apps/server/src/codework.ts b/apps/server/src/codework.ts index 7aad07fa..b0c2c398 100644 --- a/apps/server/src/codework.ts +++ b/apps/server/src/codework.ts @@ -1,26 +1,13 @@ import { Codework as Mirror } from "@codeworksh/widget/codework"; import { Codework as Client } from "@codeworksh/widget/backend"; import { NodeSocket } from "@effect/platform-node"; -import { - Config, - Context, - Deferred, - Effect, - FileSystem, - Layer, - Option, - Schedule, - Schema, - Stream, - SubscriptionRef, -} from "effect"; +import { Deferred, Effect, FileSystem, Layer, Schedule, Schema, Stream } from "effect"; import { RpcClient, RpcSerialization } from "effect/rpc"; import { Socket } from "effect/socket"; -/* @effect-diagnostics nodeBuiltinImport:off -- the default home sits under the OS home directory. */ -import os from "node:os"; +/* @effect-diagnostics nodeBuiltinImport:off -- a home's registration is a file path. */ import path from "node:path"; import { CodeworkUnavailable } from "./contract.ts"; -import { Kv } from "./kv.ts"; +import { Servers } from "./servers.ts"; export type SessionInfo = Mirror.SessionInfo; @@ -29,61 +16,18 @@ const Registration = Schema.fromJsonString( Schema.Struct({ pid: Schema.Int, version: Schema.String, url: Schema.String, port: Schema.Int }), ); -/** The same home the CLI resolves: CODEWORK_HOME_DIR, else ~/.codework. */ -const cliHome = Config.String("CODEWORK_HOME_DIR").pipe(Config.withDefault(path.join(os.homedir(), ".codework"))); - -const homeKey = { package: "codework", key: "servers.local.home" } as const; - -/** The CodeWork home the app connects through: the one chosen in Settings, else the CLI's own. */ -export class Home extends Context.Service< - Home, - { - /** The home in use; `fallback` is the one used when none is chosen. */ - readonly get: Effect.Effect<{ readonly path: string; readonly custom: boolean; readonly fallback: string }>; - /** Saves the choice; `null` goes back to the CLI's home. */ - readonly set: (path: string | null) => Effect.Effect; - /** The home's path each time the choice changes, without the current one. */ - readonly changes: Stream.Stream; - } ->()("@codeworksh/server/codework/Home") {} - -export const homeLayer = Layer.effect( - Home, +/** The server `/service.json` names, read fresh; a missing or unreadable file is none. */ +export const registrationAt = (home: string) => Effect.gen(function* () { - const kv = yield* Kv.Service; - const fallback = yield* cliHome.pipe(Effect.orDie); - const saved = yield* kv.get(homeKey.package, homeKey.key); - const chosen = yield* SubscriptionRef.make( - Option.flatMap(saved, (value) => (typeof value === "string" ? Option.some(value) : Option.none())), - ); - const get = SubscriptionRef.get(chosen).pipe( - Effect.map( - Option.match({ - onNone: () => ({ path: fallback, custom: false, fallback }), - onSome: (chosenPath) => ({ path: chosenPath, custom: true, fallback }), - }), - ), - ); - return Home.of({ - get, - set: (next) => - kv - .set(homeKey.package, homeKey.key, next) - .pipe(Effect.andThen(SubscriptionRef.set(chosen, Option.fromNullOr(next)))), - changes: SubscriptionRef.changes(chosen).pipe( - Stream.drop(1), - Stream.mapEffect(() => get), - Stream.map((current) => current.path), - ), - }); - }), -); + const fs = yield* FileSystem.FileSystem; + return yield* fs + .readFileString(path.join(home, "service.json")) + .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Registration)), Effect.option); + }); -/** The server the home's `service.json` names, read fresh; a missing or unreadable file is none. */ +/** The server the default server's home names: the one the app connects to. */ export const registration = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const file = path.join((yield* (yield* Home).get).path, "service.json"); - return yield* fs.readFileString(file).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Registration)), Effect.option); + return yield* registrationAt(yield* (yield* Servers.Service).defaultHome); }); /** @@ -105,9 +49,9 @@ const endOnHomeChange = (socket: Socket.Socket, changes: Stream.Stream): }); /** - * The app's one connection to the CodeWork server. The registration is read on every connect, so a - * server restarted on a new port, or a new home, is picked up without restarting this app; until - * one is registered, connecting waits. + * The app's one connection to a CodeWork server: the default server's. The registration is read on + * every connect, so a server restarted on a new port, a new home or a new default is picked up + * without restarting this app; until one is registered, connecting waits. */ export const layer = Layer.effect(Client, RpcClient.make(Mirror.Api, { flatten: true })).pipe( Layer.provide(RpcClient.layerProtocolSocket({ retryTransientErrors: true })), @@ -116,16 +60,16 @@ export const layer = Layer.effect(Client, RpcClient.make(Mirror.Api, { flatten: Socket.Socket, Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const home = yield* Home; + const servers = yield* Servers.Service; const url = registration.pipe( Effect.flatMap(Effect.fromOption), Effect.map((registered) => registered.url), Effect.retry(Schedule.spaced("1 second")), Effect.orDie, Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Home, home), + Effect.provideService(Servers.Service, servers), ); - return endOnHomeChange(yield* Socket.makeWebSocket(url), home.changes); + return endOnHomeChange(yield* Socket.makeWebSocket(url), servers.homeChanges); }), ).pipe(Layer.provide(NodeSocket.layerWebSocketConstructor)), ), diff --git a/apps/server/src/contract.ts b/apps/server/src/contract.ts index 61861e35..3133d243 100644 --- a/apps/server/src/contract.ts +++ b/apps/server/src/contract.ts @@ -135,12 +135,24 @@ export const CliEnvVar = Schema.Struct({ }); export type CliEnvVar = typeof CliEnvVar.Type; -export const CliStatus = Schema.Struct({ +/** Only servers on this machine for now; remote ones come later. */ +export const ServerKind = Schema.Literals(["local"]); + +/** The server the app always has, which cannot be removed. */ +export const LOCAL_SERVER = "local"; + +/** A server and how it is set up, with its executable and process as found when listed. */ +export const ServerStatus = Schema.Struct({ + id: Schema.String, + kind: ServerKind, + name: Schema.String, + /** The one the app connects to. */ + isDefault: Schema.Boolean, /** * The CodeWork home whose `service.json` names the server; `custom` when chosen in Settings, and - * `fallback` the one used otherwise. + * `fallback` the one used otherwise (only the built-in server has one). */ - home: Schema.Struct({ path: Schema.String, custom: Schema.Boolean, fallback: Schema.String }), + home: Schema.Struct({ path: Schema.String, custom: Schema.Boolean, fallback: Schema.NullOr(Schema.String) }), /** None when no `codework` was found. */ cli: Schema.NullOr(CliInstall), server: CliServer, @@ -148,23 +160,37 @@ export const CliStatus = Schema.Struct({ args: Schema.String, env: Schema.Array(CliEnvVar), }); -export type CliStatus = typeof CliStatus.Type; +export type ServerStatus = typeof ServerStatus.Type; export const CliAction = Schema.Literals(["start", "restart", "stop"]); export type CliAction = typeof CliAction.Type; +/** A new local server: it needs a home of its own, since a home names one server. */ +export const ServerAdd = Schema.Struct({ + name: Schema.String, + executable: Schema.NullOr(Schema.String), + home: Schema.String, + args: Schema.String, +}); +export type ServerAdd = typeof ServerAdd.Type; + /** - * What is chosen in Settings; an absent field is left as it is. A `null` path goes back to the one - * found; `env` replaces every variable. + * Changes to a server; an absent field is left as it is. A `null` path goes back to the one found; + * `env` replaces every variable. */ -export const CliConfig = Schema.Struct({ +export const ServerUpdate = Schema.Struct({ + id: Schema.String, + name: Schema.optionalKey(Schema.String), executable: Schema.optionalKey(Schema.NullOr(Schema.String)), home: Schema.optionalKey(Schema.NullOr(Schema.String)), args: Schema.optionalKey(Schema.String), env: Schema.optionalKey(Schema.Array(CliEnvVar)), }); +export type ServerUpdate = typeof ServerUpdate.Type; + +export const ServerId = Schema.Struct({ id: Schema.String }); -export class CliError extends Schema.TaggedError()("Server.CliError", { +export class ServersError extends Schema.TaggedError()("Server.ServersError", { message: Schema.String, }) {} @@ -215,20 +241,38 @@ export const Api = RpcGroup.make( success: Schema.Void, error: DeskError, }), - Rpc.make("cli.status", { - success: CliStatus, + /** Every server, the built-in one first, then in the order added. */ + Rpc.make("servers.list", { + success: Schema.Array(ServerStatus), + }), + Rpc.make("servers.add", { + payload: ServerAdd, + success: Schema.String, + error: ServersError, + }), + /** A new home for the default server reconnects the app at once. */ + Rpc.make("servers.update", { + payload: ServerUpdate, + success: Schema.Void, + error: ServersError, + }), + /** The default server and the built-in one stay. */ + Rpc.make("servers.remove", { + payload: ServerId, + success: Schema.Void, + error: ServersError, }), - /** Saves the executable and home chosen in Settings; the app reconnects through a new home at once. */ - Rpc.make("cli.configure", { - payload: CliConfig, + /** The app reconnects through the new default's home at once. */ + Rpc.make("servers.setDefault", { + payload: ServerId, success: Schema.Void, - error: CliError, + error: ServersError, }), - /** Runs `codework service ` with the CLI in use, in the home in use. */ - Rpc.make("cli.service", { - payload: { action: CliAction }, + /** Runs `codework service ` with the server's executable, home, arguments and variables. */ + Rpc.make("servers.service", { + payload: { id: Schema.String, action: CliAction }, success: Schema.Void, - error: CliError, + error: ServersError, }), Rpc.make("kv.get", { payload: KvKey, diff --git a/apps/server/src/db.ts b/apps/server/src/db.ts index 02185f6c..0c8721d9 100644 --- a/apps/server/src/db.ts +++ b/apps/server/src/db.ts @@ -69,6 +69,46 @@ const migrations = { ) `; }), + // The built-in local server takes over its settings, kept until now as single kv entries. + "202610180001_servers": Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE servers ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + name TEXT NOT NULL, + executable TEXT, + home TEXT, + args TEXT NOT NULL DEFAULT '', + env TEXT NOT NULL DEFAULT '[]', + is_default INTEGER NOT NULL DEFAULT 0, + pos INTEGER NOT NULL, + created INTEGER NOT NULL + ) + `; + const rows = yield* sql<{ key: string; value: string }>` + SELECT key, value FROM kv WHERE package = 'codework' AND key LIKE 'servers.local.%' + `; + const saved = new Map(rows.map(({ key, value }) => [key.slice("servers.local.".length), JSON.parse(value)])); + const text = (key: string) => { + const value: unknown = saved.get(key); + return typeof value === "string" && value !== "" ? value : null; + }; + const env: unknown = saved.get("env"); + yield* sql`INSERT INTO servers ${sql.insert({ + id: "local", + kind: "local", + name: text("name") ?? "CodeWork", + executable: text("executable"), + home: text("home"), + args: text("args") ?? "", + env: JSON.stringify(Array.isArray(env) ? env : []), + isDefault: 1, + pos: 0, + created: yield* Clock.currentTimeMillis, + })}`; + yield* sql`DELETE FROM kv WHERE package = 'codework' AND key LIKE 'servers.local.%'`; + }), }; const setup = Effect.gen(function* () { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 2e54dc4a..159386c6 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -10,6 +10,7 @@ import { Db } from "./db.ts"; import { Event } from "./event.ts"; import { Handlers } from "./handlers.ts"; import { Kv } from "./kv.ts"; +import { Servers } from "./servers.ts"; import { Widgets } from "./widgets.ts"; // The port is chosen by the OS, so the supervisor learns it from this line. @@ -31,10 +32,9 @@ const rpc = RpcServer.layerHttp({ group: Contract.Api, path: "/rpc", protocol: " // One event log, kv and CodeWork connection for the RPC handlers and the widget backends alike. export const layer = Layer.mergeAll(HttpRouter.serve(rpc, { disableListenLog: true }), Widgets.layer).pipe( - Layer.provide(Layer.mergeAll(Event.layer, Codework.layer)), - // The home is the app's own setting, and its connection goes through it, so both reach the handlers too. - Layer.provideMerge(Codework.homeLayer), - Layer.provideMerge(Kv.layer), + Layer.provide(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), Layer.provideMerge(NodeHttpServer.layer(createServer, { host: "127.0.0.1", port: 0 })), Layer.provide(RpcSerialization.layerNdjson), diff --git a/apps/server/src/servers.ts b/apps/server/src/servers.ts new file mode 100644 index 00000000..c6dfb675 --- /dev/null +++ b/apps/server/src/servers.ts @@ -0,0 +1,233 @@ +import { Clock, Config, Context, Effect, 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"; +import path from "node:path"; +import { type CliEnvVar, LOCAL_SERVER, type ServerAdd, ServersError, type ServerUpdate } from "./contract.ts"; + +/** The same home the CLI resolves: CODEWORK_HOME_DIR, else ~/.codework. Only the built-in server falls back to it. */ +const cliHome = Config.String("CODEWORK_HOME_DIR").pipe(Config.withDefault(path.join(os.homedir(), ".codework"))); + +// Stored with every value; only the app server reads the sensitive ones. +export const StoredEnv = Schema.Array( + Schema.Struct({ name: Schema.String, value: Schema.String, sensitive: Schema.Boolean }), +); +export type StoredEnv = typeof StoredEnv.Type; + +/** A server as stored. A `null` executable or home is the one found. */ +export interface Row { + readonly id: string; + readonly kind: "local"; + readonly name: string; + readonly executable: string | null; + readonly home: string | null; + readonly args: string; + readonly env: StoredEnv; + readonly isDefault: boolean; +} + +const decodeEnv = Schema.decodeUnknownEffect(Schema.fromJsonString(StoredEnv)); + +const fail = (message: string) => new ServersError({ message }); + +/** A path typed in Settings: `~` is the user's home; blank means none; anything else must be absolute. */ +const chosenPath = (input: string | null) => + Effect.gen(function* () { + const trimmed = input?.trim() ?? ""; + if (trimmed === "") return null; + const expanded = + trimmed === "~" || trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(1)) : trimmed; + if (!path.isAbsolute(expanded)) + return yield* fail("Use a full path, such as ~/.codework or /usr/local/bin/codework."); + return path.normalize(expanded); + }); + +const VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** + * The variables as chosen: names must be shell names, once each, and leave the home to its own + * setting; a sensitive one sent without a value keeps the saved value. + */ +const nextEnv = (chosen: ReadonlyArray, saved: StoredEnv) => + Effect.gen(function* () { + const names = new Set(); + const next: Array = []; + for (const { name: raw, sensitive, value } of chosen) { + const name = raw.trim(); + if (!VARIABLE_NAME.test(name)) + return yield* fail(`${name || "A blank name"} is not a variable name: use letters, digits and _.`); + if (name === "CODEWORK_HOME_DIR") return yield* fail("Set CODEWORK_HOME_DIR with the home path above."); + if (names.has(name)) return yield* fail(`${name} is set twice.`); + names.add(name); + // Only a variable that stays sensitive keeps its saved value, so none is ever revealed by unmarking it. + const kept = sensitive + ? saved.find((variable) => variable.name === name && variable.sensitive)?.value + : undefined; + next.push({ name, sensitive, value: value ?? kept ?? "" }); + } + return next; + }); + +/** + * The servers the app knows, and the one it connects to: the default, whose home's `service.json` + * names the server. The built-in local server is always there. + */ +export class Service extends Context.Service< + Service, + { + readonly list: Effect.Effect>; + readonly get: (id: string) => Effect.Effect; + /** The home a server uses; `fallback` is the built-in server's when none is chosen. */ + readonly homeOf: (row: Row) => { + readonly path: string; + readonly custom: boolean; + readonly fallback: string | null; + }; + /** The default server's home. */ + readonly defaultHome: Effect.Effect; + /** The default server's home each time it changes, without the current one. */ + readonly homeChanges: Stream.Stream; + readonly add: (input: ServerAdd) => Effect.Effect; + readonly update: (input: ServerUpdate) => Effect.Effect; + readonly remove: (id: string) => Effect.Effect; + readonly setDefault: (id: string) => Effect.Effect; + } +>()("@codeworksh/server/servers/Service") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const fallback = yield* cliHome.pipe(Effect.orDie); + + const list = sql & { kind: string; env: string; isDefault: number }>` + SELECT id, kind, name, executable, home, args, env, is_default FROM servers ORDER BY pos + `.pipe( + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => + decodeEnv(row.env).pipe( + Effect.map((env): Row => ({ ...row, kind: "local", env, isDefault: row.isDefault === 1 })), + ), + ), + ), + Effect.orDie, + ); + + const get = (id: string) => + list.pipe( + Effect.map((rows) => rows.find((row) => row.id === id)), + Effect.flatMap((row) => (row === undefined ? Effect.fail(fail(`No server ${id}`)) : Effect.succeed(row))), + ); + + const homeOf = (row: Row) => ({ + path: row.home ?? fallback, + custom: row.home !== null, + fallback: row.id === LOCAL_SERVER ? fallback : null, + }); + + const defaultHome = list.pipe(Effect.map((rows) => homeOf(rows.find((row) => row.isDefault) ?? rows[0]!).path)); + const current = yield* SubscriptionRef.make(yield* defaultHome); + // Told after every write, so a new default or a new home for it reaches the connection. + const announce = defaultHome.pipe( + Effect.flatMap((home) => + SubscriptionRef.get(current).pipe( + Effect.flatMap((was) => (was === home ? Effect.void : SubscriptionRef.set(current, home))), + ), + ), + ); + + // A home names one server, so no two servers share one. + const ensureOwnHome = (id: string | null, home: string) => + list.pipe( + Effect.flatMap((rows) => { + const taken = rows.find((row) => row.id !== id && homeOf(row).path === home); + return taken === undefined + ? Effect.void + : Effect.fail(fail(`${taken.name} already uses ${home}; each server needs its own home.`)); + }), + ); + + const name = (input: string) => { + const trimmed = input.trim(); + return trimmed === "" ? Effect.fail(fail("A server needs a name.")) : Effect.succeed(trimmed); + }; + + const transaction = (effect: Effect.Effect) => + sql.withTransaction(effect).pipe(Effect.catchTag("SqlError", Effect.die)); + + return Service.of({ + list, + get, + homeOf, + defaultHome, + homeChanges: SubscriptionRef.changes(current).pipe(Stream.drop(1)), + + add: Effect.fn("Servers.add")(function* (input) { + const home = yield* chosenPath(input.home); + if (home === null) return yield* fail("A new server needs a home of its own."); + const id = yield* Effect.sync(() => crypto.randomUUID().slice(0, 8)); + yield* transaction( + Effect.gen(function* () { + yield* ensureOwnHome(null, home); + const [last] = yield* sql<{ pos: number }>`SELECT COALESCE(MAX(pos), 0) AS pos FROM servers`; + yield* sql`INSERT INTO servers ${sql.insert({ + id, + kind: "local", + name: yield* name(input.name), + executable: yield* chosenPath(input.executable), + home, + args: input.args.trim(), + env: "[]", + isDefault: 0, + pos: (last?.pos ?? 0) + 1, + created: yield* Clock.currentTimeMillis, + })}`; + }), + ); + return id; + }), + + update: Effect.fn("Servers.update")(function* ({ id, ...patch }) { + yield* transaction( + Effect.gen(function* () { + 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.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) { + const home = yield* chosenPath(patch.home); + if (home === null && row.id !== LOCAL_SERVER) + return yield* fail("An added server keeps a home of its own."); + yield* ensureOwnHome(id, home ?? fallback); + fields.home = home; + } + if (Object.keys(fields).length > 0) + yield* sql`UPDATE servers SET ${sql.update(fields)} WHERE id = ${id}`; + }), + ); + yield* announce; + }), + + remove: Effect.fn("Servers.remove")(function* (id) { + const row = yield* get(id); + if (row.id === LOCAL_SERVER) return yield* fail("The built-in local server cannot be removed."); + if (row.isDefault) return yield* fail("Make another server the default before removing this one."); + yield* sql`DELETE FROM servers WHERE id = ${id}`.pipe(Effect.orDie); + }), + + setDefault: Effect.fn("Servers.setDefault")(function* (id) { + yield* transaction( + Effect.gen(function* () { + yield* get(id); + yield* sql`UPDATE servers SET is_default = CASE WHEN id = ${id} THEN 1 ELSE 0 END`; + }), + ); + yield* announce; + }), + }); + }), +); + +export * as Servers from "./servers.ts"; diff --git a/apps/web/src/components/ui/switch.tsx b/apps/web/src/components/ui/switch.tsx new file mode 100644 index 00000000..3503cf8b --- /dev/null +++ b/apps/web/src/components/ui/switch.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; +import { Switch as SwitchPrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; + +function Switch({ className, ...props }: React.ComponentProps) { + return ( + + + + ); +} + +export { Switch }; diff --git a/apps/web/src/settings/servers/add.tsx b/apps/web/src/settings/servers/add.tsx new file mode 100644 index 00000000..384fd746 --- /dev/null +++ b/apps/web/src/settings/servers/add.tsx @@ -0,0 +1,239 @@ +import { useAtomSet } from "@effect/atom-react"; +import { Check, Globe } from "lucide-react"; +import { type ReactNode, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +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"; + +const addAtom = Server.mutation("servers.add"); + +const STEPS = ["Kind", "Identity", "Config"] as const; + +/** + * Adds a server in three steps, as a provider is added elsewhere: what kind, what it is called, how + * it runs. Only local servers for now. Nothing is saved until the last step. + */ +export function AddServer({ + onClose, + defaultName, + detected, + onAdded, +}: { + onClose: () => void; + defaultName: string; + /** The `codework` found without a choice, shown as the executable's default. */ + detected: string | undefined; + onAdded: (id: string) => void; +}) { + const add = useAtomSet(addAtom, { mode: "promise" }); + const [step, setStep] = useState(0); + const [name, setName] = useState(defaultName); + const [executable, setExecutable] = useState(""); + const [home, setHome] = useState(""); + // A free port, so the new server runs beside the others rather than on the default one. + const [args, setArgs] = useState("--port 0"); + const [adding, setAdding] = useState(false); + const [error, setError] = useState(null); + + const submit = () => { + setAdding(true); + setError(null); + add({ + payload: { name, executable: executable.trim() || null, home, args }, + reactivityKeys: serversKey, + }) + .then((id) => { + onAdded(id); + onClose(); + }) + .catch((cause: unknown) => setError(messageOf(cause))) + .finally(() => setAdding(false)); + }; + + const canContinue = step === 0 || (step === 1 && name.trim() !== "") || (step === 2 && home.trim() !== ""); + + return ( + !next && onClose()}> + + + Add server + Run another codework server on this machine, with its own home. + + +
    + {STEPS.map((label, index) => ( +
  1. + + {index < step ? : index + 1} + + {label} +
  2. + ))} +
+ + {step === 0 && ( +
+ Kind +
+ } title="Local" detail="Runs on this machine" /> + } + title="Remote" + detail="Another machine's server" + badge={Coming soon} + /> +
+
+ )} + + {step === 1 && ( +
+ + setName(event.target.value)} + onKeyDown={(event) => event.key === "Enter" && canContinue && setStep(2)} + /> + +
+ )} + + {step === 2 && ( +
+ + setExecutable(event.target.value)} + /> + + + CODEWORK_HOME_DIR path + + } + description="codework home and config directory, its own: a home names one server." + > + setHome(event.target.value)} + /> + + + Additional CLI arguments passed to codework service start. --port 0{" "} + picks a free port. + + } + > + setArgs(event.target.value)} + /> + +
+ )} + + {error !== null &&

{error}

} + + + {step === 0 ? ( + + ) : ( + + )} + {step < 2 ? ( + + ) : ( + + )} + +
+
+ ); +} + +/** One kind of server to pick; a tile without `selected` cannot be picked yet. */ +function KindTile({ + selected = false, + icon, + title, + detail, + badge, +}: { + selected?: boolean; + icon: ReactNode; + title: string; + detail: string; + badge?: ReactNode; +}) { + return ( +
+ {icon} + + {title} + {detail} + + {badge} + {selected && } +
+ ); +} + +/** A setting in the dialog: its title and description beside its field. */ +function Row({ title, description, children }: { title: ReactNode; description: ReactNode; children: ReactNode }) { + return ( +
+ + {title} + {description} + + {children} +
+ ); +} diff --git a/apps/web/src/settings/servers/local.tsx b/apps/web/src/settings/servers/local.tsx index a3544254..3510eb8f 100644 --- a/apps/web/src/settings/servers/local.tsx +++ b/apps/web/src/settings/servers/local.tsx @@ -1,36 +1,35 @@ -import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import type { CliAction, CliConfig, CliStatus } from "@codeworksh/server/contract"; -import { Check, Copy, TriangleAlert, Undo2 } from "lucide-react"; -import { Option } from "effect"; -import { AsyncResult } from "effect/reactivity"; +import { useAtomSet } from "@effect/atom-react"; +import { type CliAction, LOCAL_SERVER, type ServerStatus, type ServerUpdate } from "@codeworksh/server/contract"; +import { Check, Copy, Trash2, TriangleAlert, Undo2 } from "lucide-react"; import { type ReactNode, useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + 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"; import { Code, quietField as field, SavingInput } from "../blocks"; import { Variables } from "./variables"; -export const cliKey = ["cli"]; -export const statusAtom = Server.query("cli.status", undefined, { reactivityKeys: cliKey }); -const serviceAtom = Server.mutation("cli.service"); -const configureAtom = Server.mutation("cli.configure"); +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"); +const setDefaultAtom = Server.mutation("servers.setDefault"); -// The name is the app's own, kept in its kv store under the built-in package. -const nameKey = { package: "codework", key: "servers.local.name" } as const; -const nameKeys = ["servers.local.name"]; -const nameAtom = Server.query("kv.get", nameKey, { reactivityKeys: nameKeys }); -const saveNameAtom = Server.mutation("kv.set"); - -/** The local server's name: "CodeWork" until renamed. */ -export function useLocalName() { - const result = useAtomValue(nameAtom); - const saved = AsyncResult.isSuccess(result) ? Option.getOrUndefined(result.value) : undefined; - return typeof saved === "string" ? saved : "CodeWork"; -} - -const INSTALL = "curl -fsSL https://codework.sh/install | sh"; +export const INSTALL = "curl -fsSL https://codework.sh/install | sh"; type Tone = "success" | "warning" | "danger" | "info" | "muted"; @@ -47,28 +46,26 @@ export const Badge = ({ tone, children }: { tone: Tone; children: ReactNode }) = {children} ); -const Mark = () => ; +export const Mark = () => ; const Version = ({ version }: { version: string }) => ( v{version} ); -/** The local server's row in the list: always there, and the default. */ -export function LocalItem({ status, selected }: { status: CliStatus | undefined; selected: boolean }) { - const name = useLocalName(); - const cli = status?.cli; +/** A server's row in the list: its name and binary, and whether it is the default. */ +export function LocalItem({ status }: { status: ServerStatus }) { + const { cli } = status; // Only the binary is judged here; the server's own state belongs to the detail. - const problem = - cli === undefined ? undefined : cli === null ? "No codework found" : "failure" in cli ? cli.failure : undefined; + const problem = cli === null ? "No codework found" : "failure" in cli ? cli.failure : undefined; return ( -
+
- {name} - {cli != null && "version" in cli && } + {status.name} + {cli !== null && "version" in cli && } {problem !== undefined && ( {problem} @@ -77,27 +74,25 @@ export function LocalItem({ status, selected }: { status: CliStatus | undefined; Local - Default + {status.isDefault && Default}
); } -/** The local server: its name, its state and controls, then the executable that runs it and its home. */ -export function LocalDetail({ status }: { status: CliStatus }) { +/** 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 service = useAtomSet(serviceAtom, { mode: "promise" }); - const saveName = useAtomSet(saveNameAtom, { mode: "promise" }); - const saveConfig = useAtomSet(configureAtom, { mode: "promise" }); - const name = useLocalName(); + const update = useAtomSet(updateAtom, { mode: "promise" }); const [running, setRunning] = useState(null); const [error, setError] = useState(null); const [nameError, setNameError] = useState(null); - const { server, cli } = status; + const { id, server, cli } = status; const usable = cli !== null && "version" in cli; - const configure = (config: typeof CliConfig.Type) => - saveConfig({ payload: config, reactivityKeys: cliKey }).then( + const configure = (patch: Omit) => + update({ payload: { id, ...patch }, reactivityKeys: serversKey }).then( () => undefined, (cause: unknown) => messageOf(cause), ); @@ -105,32 +100,34 @@ export function LocalDetail({ status }: { status: CliStatus }) { const act = (action: CliAction) => { setError(null); setRunning(action); - service({ payload: { action }, reactivityKeys: cliKey }) + service({ payload: { id, action }, reactivityKeys: serversKey }) .catch((cause: unknown) => setError(messageOf(cause))) .finally(() => setRunning(null)); }; return (
-
+ {/* As tall as its buttons, so a server that can be removed does not sit lower than one that cannot. */} +
- {name} + {status.name} Local + {status.isDefault && Default} + +
{ // The server keeps its name when the field is cleared. if (!next) return; setNameError(null); - saveName({ payload: { ...nameKey, value: next }, reactivityKeys: nameKeys }).catch( - (cause: unknown) => setNameError(messageOf(cause)), - ); + void configure({ name: next }).then((failure) => setNameError(failure ?? null)); }} /> {nameError !== null && {nameError}} @@ -192,8 +189,10 @@ export function LocalDetail({ status }: { status: CliStatus }) { } label="CODEWORK_HOME_DIR path" description="codework home and config directory." - value={status.home.custom ? status.home.path : ""} - placeholder={status.home.fallback} + // An added server always has its own home, so only the built-in one shows a default to reset to. + value={status.home.fallback === null || status.home.custom ? status.home.path : ""} + placeholder={status.home.fallback ?? "~/.codework-2"} + resettable={status.home.fallback !== null} onSave={(next) => configure({ home: next || null })} /> ( +/** + * 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 }) { + const setDefault = useAtomSet(setDefaultAtom, { mode: "promise" }); + const [switching, setSwitching] = useState(false); + const [error, setError] = useState(null); + return ( + { + if (!on) return; + setSwitching(true); + setError(null); + setDefault({ payload: { id: status.id }, reactivityKeys: serversKey }) + .catch((cause: unknown) => setError(messageOf(cause))) + .finally(() => setSwitching(false)); + }} + /> + ); +} + +/** Remove, for the servers it applies to: the built-in one and the default stay. */ +function ServerActions({ status, onError }: { status: ServerStatus; onError: (message: string) => void }) { + const remove = useAtomSet(removeAtom, { mode: "promise" }); + const [busy, setBusy] = useState(false); + const run = (action: () => Promise) => { + setBusy(true); + action() + .catch((cause: unknown) => onError(messageOf(cause))) + .finally(() => setBusy(false)); + }; + return ( + <> + {!status.isDefault && status.id !== LOCAL_SERVER && ( + + + + + + + Remove {status.name}? + + The app forgets this server's settings. Its home, and a server running from it, are left as they + are. + + + + Cancel + run(() => remove({ payload: { id: status.id }, reactivityKeys: serversKey }))} + > + Remove + + + + + )} + + ); +} + +export const Card = ({ children }: { children: ReactNode }) => (
{children}
); @@ -251,6 +330,7 @@ function SettingField({ description, value, placeholder, + resettable = true, onSave, children, }: { @@ -260,6 +340,8 @@ function SettingField({ description: ReactNode; value: string; placeholder: string; + /** Whether a set value can go back to the default; an added server's home has none. */ + resettable?: boolean; onSave: (value: string) => Promise; children?: ReactNode; }) { @@ -283,7 +365,7 @@ function SettingField({ className={`${field} font-mono text-[12px] md:text-[12px]`} onCommit={save} /> - {value !== "" && ( + {resettable && value !== "" && (
-
+
+ {/* Mounted only while open, so each opening starts fresh and is named after the servers there are. */} + {adding && ( + setAdding(false)} + defaultName={`CodeWork ${(servers?.length ?? 0) + 1}`} + detected={detected} + onAdded={setSelectedId} + /> + )}
- {status !== undefined ? ( - + {selected !== undefined ? ( + ) : AsyncResult.isFailure(result) ? (

The app server did not answer.

) : ( From 8e255998229dc86dd559aa6f19c7f14a6c5ebcc2 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sun, 18 Oct 2026 01:28:47 +0530 Subject: [PATCH 18/20] fix(desktop): stop the app server with the app The app server never exited on SIGTERM: waiting for a CodeWork server to register ran inside the socket's uninterruptible open, and closing the listener waited for the page's WebSocket forever. The wait is now interruptible and closing ends the connections left. Electron turns SIGTERM and SIGINT into a normal quit and stops the app server on quit, killing it after 5 seconds; the app server also stops itself once its parent is gone, so Electron killed outright no longer leaves it behind. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/main.ts | 4 ++++ apps/desktop/src/server.ts | 21 ++++++++++++++++- apps/server/src/codework.ts | 3 +++ apps/server/src/server.ts | 45 ++++++++++++++++++++++++++++++++++--- 4 files changed, 69 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 61a075a2..61b85477 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -157,3 +157,7 @@ void app.whenReady().then(async () => { app.on("window-all-closed", () => { if (process.platform !== "darwin") app.quit(); }); + +// A signal (the dev runner restarting, a terminal's Ctrl+C) quits as the app does, so the app server +// is stopped too; left to the default, Electron would exit at once and leave it running. +for (const signal of ["SIGTERM", "SIGINT"] as const) process.on(signal, () => app.quit()); diff --git a/apps/desktop/src/server.ts b/apps/desktop/src/server.ts index c0b0aa33..515721b3 100644 --- a/apps/desktop/src/server.ts +++ b/apps/desktop/src/server.ts @@ -7,6 +7,9 @@ import { SERVER_CHANNEL } from "./ipc.ts"; // Dev layout: the server bundle sits beside this app in the workspace. const ENTRY = path.resolve(app.getAppPath(), "../server/dist/index.mjs"); +// How long the server gets to stop by itself on quit before it is killed. +const STOP_GRACE_MS = 5000; + /** Starts the app server and resolves with its RPC URL once it listens. */ export function startServer(): Promise { // Electron's own binary in Node mode: no system Node needed, and the child @@ -20,11 +23,27 @@ export function startServer(): Promise { stdio: ["ignore", "pipe", "inherit"], }); - app.on("will-quit", () => child.kill()); + // Quitting waits for the server to stop, asked with SIGTERM and killed if it outlasts the grace, so + // the app never leaves it running. + let stopping = false; + const running = () => child.exitCode === null && child.signalCode === null; + app.on("before-quit", (event) => { + if (!running()) return; + event.preventDefault(); + if (stopping) return; + stopping = true; + const force = setTimeout(() => child.kill("SIGKILL"), STOP_GRACE_MS); + child.once("exit", () => { + clearTimeout(force); + app.quit(); + }); + child.kill("SIGTERM"); + }); return new Promise((resolve, reject) => { let ready = false; child.once("exit", (code, signal) => { + if (stopping) return; if (!ready) return reject(new Error(`server exited before ready (code ${code}, signal ${signal})`)); // No restart yet: the app is unusable without its server, so it ends // with it and the dev runner (or the user) starts it again. diff --git a/apps/server/src/codework.ts b/apps/server/src/codework.ts index b0c2c398..97897234 100644 --- a/apps/server/src/codework.ts +++ b/apps/server/src/codework.ts @@ -68,6 +68,9 @@ export const layer = Layer.effect(Client, RpcClient.make(Mirror.Api, { flatten: Effect.orDie, Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Servers.Service, servers), + // The socket reads its URL while opening, which Effect runs uninterruptibly; waiting there + // for a server to register would otherwise keep the app server from ever shutting down. + Effect.interruptible, ); return endOnHomeChange(yield* Socket.makeWebSocket(url), servers.homeChanges); }), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 159386c6..941bbb8a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,9 +1,10 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node"; -import { Config, Effect, Layer } from "effect"; +import { Config, Effect, Layer, Schedule } from "effect"; import { HttpRouter, HttpServer } from "effect/http"; import { RpcSerialization, RpcServer } from "effect/rpc"; /* @effect-diagnostics nodeBuiltinImport:off -- the RPC listener needs a raw node server. */ import { createServer } from "node:http"; +import type { Socket } from "node:net"; import { Codework } from "./codework.ts"; import { Contract } from "./contract.ts"; import { Db } from "./db.ts"; @@ -13,6 +14,25 @@ import { Kv } from "./kv.ts"; import { Servers } from "./servers.ts"; import { Widgets } from "./widgets.ts"; +/** + * The RPC listener. Closing a Node server waits for every connection to end, and the app's page keeps + * its WebSocket open, so closing also ends the connections left: on shutdown nobody needs them. + */ +const listener = () => { + const server = createServer(); + const sockets = new Set(); + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + const close = server.close.bind(server); + server.close = (callback) => { + for (const socket of sockets) socket.destroy(); + return close(callback); + }; + return server; +}; + // The port is chosen by the OS, so the supervisor learns it from this line. const announceReady = Layer.effectDiscard( Effect.gen(function* () { @@ -23,6 +43,21 @@ const announceReady = Layer.effectDiscard( }), ); +/** + * Stops this process once the one that started it is gone: Electron killed outright, or crashed, cannot + * stop it, and an app server nobody can reach would only hold the database. It stops as on SIGTERM. + */ +const exitWithParent = Layer.effectDiscard( + Effect.gen(function* () { + const parent = process.ppid; + yield* Effect.sync(() => process.ppid).pipe( + Effect.repeat({ while: (ppid) => ppid === parent, schedule: Schedule.spaced("1 second") }), + Effect.andThen(Effect.sync(() => process.kill(process.pid, "SIGTERM"))), + Effect.forkScoped, + ); + }), +); + // Electron main sets the path to a file in its userData folder. const database = Layer.unwrap(Effect.map(Config.String("CODEWORK_DATABASE"), Db.layer)).pipe(Layer.orDie); @@ -31,12 +66,16 @@ const rpc = RpcServer.layerHttp({ group: Contract.Api, path: "/rpc", protocol: " ); // One event log, kv and CodeWork connection for the RPC handlers and the widget backends alike. -export const layer = Layer.mergeAll(HttpRouter.serve(rpc, { disableListenLog: true }), Widgets.layer).pipe( +export const layer = Layer.mergeAll( + HttpRouter.serve(rpc, { disableListenLog: true }), + Widgets.layer, + exitWithParent, +).pipe( Layer.provide(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), - Layer.provideMerge(NodeHttpServer.layer(createServer, { host: "127.0.0.1", port: 0 })), + Layer.provideMerge(NodeHttpServer.layer(listener, { host: "127.0.0.1", port: 0 })), Layer.provide(RpcSerialization.layerNdjson), Layer.provide(database), Layer.provide(NodeServices.layer), From aa6f10032b011365024b54735fc99ec692f1fa6f Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sun, 18 Oct 2026 01:44:03 +0530 Subject: [PATCH 19/20] feat(web): show the server's state in the sidebar when sessions cannot load Instead of the raw "No usable reply" error, the sidebar says what is wrong with the default server and offers the fix: Start Server when it is not running, Restart when it does not answer, Set Up when codework is not found, and Server Settings otherwise. After a start the list loads in place. Start and restart share their logic with the Servers page, and the sidebar's empty states share one look. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/landing/empty.tsx | 35 ++++++ apps/web/src/landing/offline.tsx | 135 ++++++++++++++++++++++++ apps/web/src/landing/sidebar.tsx | 28 +++-- apps/web/src/settings/servers/local.tsx | 36 +++++-- 4 files changed, 212 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/landing/empty.tsx create mode 100644 apps/web/src/landing/offline.tsx diff --git a/apps/web/src/landing/empty.tsx b/apps/web/src/landing/empty.tsx new file mode 100644 index 00000000..8b2581f6 --- /dev/null +++ b/apps/web/src/landing/empty.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from "react"; + +/** The sidebar's message when it has nothing to list: a few quiet lines, centered, and what to do next. */ +export function Empty({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +/** The one thing to do from an empty sidebar, as a small outlined button. */ +export function EmptyAction({ + icon, + disabled = false, + onClick, + children, +}: { + icon?: ReactNode; + disabled?: boolean; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} diff --git a/apps/web/src/landing/offline.tsx b/apps/web/src/landing/offline.tsx new file mode 100644 index 00000000..8e310f54 --- /dev/null +++ b/apps/web/src/landing/offline.tsx @@ -0,0 +1,135 @@ +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import { useNavigate } from "@tanstack/react-router"; +import { AsyncResult } from "effect/reactivity"; +import { Play, RotateCw, Settings } from "lucide-react"; +import type { ReactNode } from "react"; + +import { serversAtom, useServerAction } from "../settings/servers/local"; +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 { running, error, act } = useServerAction(); + const server = AsyncResult.isSuccess(servers) ? servers.value.find((candidate) => 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 ( + + {AsyncResult.isFailure(servers) && settingsLink} + + ); + + const { cli } = server; + if (cli === null) + return ( + + } onClick={settings}> + Set Up + + + ); + if ("failure" in cli) + return ( + + } onClick={settings}> + Server Settings + + + ); + + 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 + + + ); + } +} + +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 4e0bd334..c617291f 100644 --- a/apps/web/src/landing/sidebar.tsx +++ b/apps/web/src/landing/sidebar.tsx @@ -15,6 +15,8 @@ import { deskAtom } from "../wm/instances"; import { useOpenElsewhere } from "../wm/open"; import { ago } from "../widgets/time"; import { Monogram } from "@/components/monogram"; +import { Empty, EmptyAction } from "./empty"; +import { ServerOffline } from "./offline"; import { placeAtom, usePickFolder } from "./place"; /** @@ -74,37 +76,33 @@ export function SessionsSidebar() { {AsyncResult.isFailure(result) ? ( -

- {Option.match(AsyncResult.error(result), { - // Says how to start the CodeWork server when that is the reason. - onSome: (error) => - error._tag === "Server.CodeworkUnavailable" ? error.message : "Could not load sessions.", - onNone: () => "Could not load sessions.", - })} -

+ // 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.

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

No sessions match “{query}”.

) : ( -
+ {place.kind === "none" && result.value.length === 0 ? ( <> No Projects Yet {addProject !== undefined && ( - + )} ) : ( No Sessions Yet )} -
+ ) ) : (
    diff --git a/apps/web/src/settings/servers/local.tsx b/apps/web/src/settings/servers/local.tsx index 3510eb8f..bd044b07 100644 --- a/apps/web/src/settings/servers/local.tsx +++ b/apps/web/src/settings/servers/local.tsx @@ -52,6 +52,30 @@ const Version = ({ version }: { version: string }) => ( v{version} ); +/** + * 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. + */ +export 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 }) + .then( + () => true, + (cause: unknown) => { + setError(messageOf(cause)); + return false; + }, + ) + .finally(() => setRunning(null)); + }; + return { running, error, act }; +} + /** A server's row in the list: its name and binary, and whether it is the default. */ export function LocalItem({ status }: { status: ServerStatus }) { const { cli } = status; @@ -83,9 +107,8 @@ export function LocalItem({ status }: { status: ServerStatus }) { /** 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 service = useAtomSet(serviceAtom, { mode: "promise" }); const update = useAtomSet(updateAtom, { mode: "promise" }); - const [running, setRunning] = useState(null); + const { running, error: actionError, act: run } = useServerAction(); const [error, setError] = useState(null); const [nameError, setNameError] = useState(null); const { id, server, cli } = status; @@ -99,10 +122,7 @@ export function LocalDetail({ status }: { status: ServerStatus }) { const act = (action: CliAction) => { setError(null); - setRunning(action); - service({ payload: { id, action }, reactivityKeys: serversKey }) - .catch((cause: unknown) => setError(messageOf(cause))) - .finally(() => setRunning(null)); + void run(id, action); }; return ( @@ -158,7 +178,9 @@ export function LocalDetail({ status }: { status: ServerStatus }) {
)}
- {error !== null &&

{error}

} + {(actionError ?? error) !== null && ( +

{actionError ?? error}

+ )}
From 40a8e95a7aa77d818104f266cfcf4c221113381a Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sun, 18 Oct 2026 01:44:03 +0530 Subject: [PATCH 20/20] fix(server): bound the app server's shutdown Once asked to stop, the app server now exits within 5 seconds even if something is still winding down, since an orphaned one has nobody left to kill it. Being adopted by launchd also counts as losing its parent, for an app server whose Electron was gone before it started watching. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/index.ts | 6 ++++++ apps/server/src/server.ts | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 7db85568..30373209 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -2,4 +2,10 @@ import { NodeRuntime } from "@effect/platform-node"; import { Layer } from "effect"; import { Server } from "./server.ts"; +// A shutdown that hangs would leave this process running with nobody left to stop it, so once asked to +// stop it ends within this long whatever is still winding down. +const SHUTDOWN_GRACE_MS = 5000; +for (const signal of ["SIGTERM", "SIGINT"] as const) + process.once(signal, () => setTimeout(() => process.exit(1), SHUTDOWN_GRACE_MS).unref()); + NodeRuntime.runMain(Layer.launch(Server.layer)); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 941bbb8a..9b42ff14 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -51,7 +51,8 @@ const exitWithParent = Layer.effectDiscard( Effect.gen(function* () { const parent = process.ppid; yield* Effect.sync(() => process.ppid).pipe( - Effect.repeat({ while: (ppid) => ppid === parent, schedule: Schedule.spaced("1 second") }), + // Adopted by launchd (pid 1) is orphaned too, even when the parent was gone before this started. + Effect.repeat({ while: (ppid) => ppid === parent && ppid !== 1, schedule: Schedule.spaced("1 second") }), Effect.andThen(Effect.sync(() => process.kill(process.pid, "SIGTERM"))), Effect.forkScoped, );