From c36406cca88dd08426e866934cd86e0651dd47c4 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Tue, 10 Nov 2026 22:40:07 +0530 Subject: [PATCH 1/4] feat(web): frame menu with thread rows, sidebar current and color, menu motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Frames get a ⋯ menu: Move (workspaces, New Tab) and, for sessions, Status, Priority, Group and Color with their current values. The session frame keeps the thread list mounted so the menu opens whole. - Sidebar marks the focused session with a background and other sessions on screen with full text, at one font weight; a thread's color washes in from the row's right edge. - The sidebar takes the main thread's color over the session's, as All Threads does, and refreshes on a thread color change. - Display menus show their current grouping and ordering. - Menus slide out of their trigger without scaling; submenus slide out of their row toward the side they open on. Co-Authored-By: Claude Opus 5.5 --- apps/contract/src/contract.ts | 2 +- apps/server/src/reads.ts | 3 +- apps/web/src/components/display.tsx | 2 +- apps/web/src/components/menu.tsx | 15 ++- apps/web/src/components/ui/dropdown-menu.tsx | 9 +- apps/web/src/landing/sidebar.tsx | 53 +++++++-- apps/web/src/widgets/session/menu.tsx | 115 +++++++++++++++++++ apps/web/src/widgets/session/projects.ts | 6 +- apps/web/src/widgets/session/view.tsx | 4 +- apps/web/src/widgets/threads/toolbar.tsx | 2 + apps/web/src/wm/desktop.tsx | 3 + apps/web/src/wm/frame.tsx | 62 +++++++--- 12 files changed, 233 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/widgets/session/menu.tsx diff --git a/apps/contract/src/contract.ts b/apps/contract/src/contract.ts index d68560ef..8a800782 100644 --- a/apps/contract/src/contract.ts +++ b/apps/contract/src/contract.ts @@ -47,7 +47,7 @@ export const SidebarSession = Schema.Struct({ /** Its main thread. */ threadId: Schema.String, spaceKind: SpaceInfo.fields.kind, - /** Hex; null is the default. */ + /** Its main thread's color, else its own, as All Threads shows it; hex, null is the default. */ color: Schema.NullOr(Schema.String), /** The main thread's title, or what an untitled one is called. */ title: Schema.String, diff --git a/apps/server/src/reads.ts b/apps/server/src/reads.ts index e8043823..4dae22f3 100644 --- a/apps/server/src/reads.ts +++ b/apps/server/src/reads.ts @@ -80,7 +80,8 @@ export const sidebar = Effect.fn("Reads.sidebar")(function* ({ createdAt: number; updatedAt: number; }>` - SELECT session.id, session.machine_id, space.project_id, space.kind AS space_kind, session.color, + SELECT session.id, session.machine_id, space.project_id, space.kind AS space_kind, + COALESCE(thread.color, session.color) AS color, session.dir, session.hostdir, thread.id AS thread_id, thread.title, thread.model_provider, thread.model_id, thread.run_state, thread.status, thread.priority, thread.created_at, (SELECT MAX(activity.updated_at) FROM thread AS activity diff --git a/apps/web/src/components/display.tsx b/apps/web/src/components/display.tsx index 143a4e62..31de5d95 100644 --- a/apps/web/src/components/display.tsx +++ b/apps/web/src/components/display.tsx @@ -29,7 +29,7 @@ export function DisplayMenu({ - + {children} diff --git a/apps/web/src/components/menu.tsx b/apps/web/src/components/menu.tsx index a9b4c646..fc219968 100644 --- a/apps/web/src/components/menu.tsx +++ b/apps/web/src/components/menu.tsx @@ -25,12 +25,14 @@ export function MenuCheck() { /** * One choice of a menu: its mark and name, opening its picks beside it with the current one checked. - * `after` holds actions rather than picks, such as clearing it, below a separator. + * `current` names the pick on the row itself, before the chevron. `after` holds actions rather than + * picks, such as clearing it, below a separator. */ export function MenuChoice({ label, mark, value, + current, options, onChange, after, @@ -38,15 +40,22 @@ export function MenuChoice({ readonly label: string; readonly mark: ReactNode; readonly value: A | undefined; + readonly current?: string | undefined; readonly options: ReadonlyArray<{ readonly value: A; readonly label: string; readonly mark: ReactNode }>; readonly onChange: (value: A) => void; readonly after?: ReactNode; }) { return ( - svg:last-child]:size-3.5`}> - {mark} + svg:last-child]:size-3.5 ${current === undefined ? "" : "[&>svg:last-child]:ml-0"}`} + > + {/* One width for every mark, so a dot's label lines up with an icon's. */} + {mark} {label} + {current !== undefined && ( + {current} + )} {/* Shifted up by its padding and border, so its first row lines up with the trigger. */} diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx index 5bd29baa..a336fb00 100644 --- a/apps/web/src/components/ui/dropdown-menu.tsx +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -15,6 +15,8 @@ function DropdownMenuTrigger({ ...props }: React.ComponentProps; } +// A menu comes out of its trigger: a short slide away from it as it fades in, no growth. It leaves +// by fading alone, quicker than it came. function DropdownMenuContent({ className, sideOffset = 4, @@ -26,7 +28,7 @@ function DropdownMenuContent({ data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn( - "z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-[12px] border bg-popover p-1 text-[13px] text-popover-foreground shadow-(--cw-menu-shadow) [-webkit-app-region:no-drag] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:duration-100 data-[state=closed]:ease-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:duration-150 data-[state=open]:ease-out data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", + "z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-[12px] border bg-popover p-1 text-[13px] text-popover-foreground shadow-(--cw-menu-shadow) [-webkit-app-region:no-drag] data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:duration-100 data-[state=closed]:ease-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:duration-150 data-[state=open]:ease-(--ease-out) data-[state=open]:fade-in-0 motion-reduce:animate-none", className, )} {...props} @@ -179,7 +181,8 @@ function DropdownMenuSubTrigger({ ); } -// Submenus open as an OS menu's do, at once: they are browsed by hover many times in a row. +// A submenu slides out of its row toward the side it opens on, shorter than a menu: rows are browsed +// by hover many times in a row. It closes at once, so moving to the next row never shows two. function DropdownMenuSubContent({ className, ...props @@ -188,7 +191,7 @@ function DropdownMenuSubContent({ instance.id === focusedId)?.address; + const workspace = useWorkspaces().find((candidate) => candidate.id === workspaceId); + const focusedId = workspace === undefined ? undefined : focusedOn.get(workspace.id); + const current = threadRef(instances.find((instance) => instance.id === focusedId)?.address)?.sessionId; + const shown = new Set( + workspace === undefined + ? [] + : instances.flatMap((instance) => + (instance.tags & workspaceTag(workspace)) === 0 ? [] : (threadRef(instance.address)?.sessionId ?? []), + ), + ); // A new session starts on the start page, in the current session's project when one is in front. const startNew = () => { const project = AsyncResult.isSuccess(result) - ? result.value.find((candidate) => candidate.sessions.some(({ id }) => sessionAddress(id) === current)) + ? result.value.find((candidate) => candidate.sessions.some(({ id }) => id === current)) : undefined; if (project !== undefined) setPlace({ kind: "folder", path: project.path }); void navigate({ to: "/" }); @@ -200,6 +209,7 @@ export function SessionsSidebar() { label="Grouping" mark={} value={display.group} + current={GROUPS.find(({ value }) => value === display.group)?.label} options={marked(GROUPS)} onChange={(group) => changeDisplay({ group })} /> @@ -207,6 +217,7 @@ export function SessionsSidebar() { label="Ordering" mark={} value={display.order} + current={ORDERS.find(({ value }) => value === display.order)?.label} options={marked(ORDERS)} onChange={(order) => changeDisplay({ order })} /> @@ -218,6 +229,7 @@ export function SessionsSidebar() { group={group} expanded={needle !== "" || !collapsed.has(group.key)} current={current} + shown={shown} onToggle={() => toggle(group.key)} onStartNew={group.kind === "project" ? () => startIn(group.project.path) : undefined} onOpen={open} @@ -248,27 +260,31 @@ function GroupSection({ group, expanded, current, + shown, onToggle, onStartNew, onOpen, }: { readonly group: Group; readonly expanded: boolean; + /** The session id in front on the workspace on screen. */ readonly current: string | undefined; + /** Every session id the workspace on screen shows. */ + readonly shown: ReadonlySet; readonly onToggle: () => void; /** A project's new thread; other groups have none. */ readonly onStartNew: (() => void) | undefined; readonly onOpen: (sessionId: string, projectName: string, event: MouseEvent) => void; }) { const label = groupLabel(group); - const open = expanded ? undefined : group.entries.find(({ session }) => sessionAddress(session.id) === current); + const open = expanded ? undefined : group.entries.find(({ session }) => session.id === current); const row = ({ session, project }: Entry) => ( ); @@ -328,14 +344,15 @@ const SessionRow = memo(function SessionRow({ session, projectName, machineName, - current, + state, onOpen, }: { /** Kept across reads while it does not change, with the project's parts as plain values, so the row renders only for its own changes. */ readonly session: SidebarSession; readonly projectName: string; readonly machineName: string; - readonly current: boolean; + /** In front on the workspace on screen, or shown there in another frame. */ + readonly state: "current" | "shown" | undefined; readonly onOpen: (sessionId: string, projectName: string, event: MouseEvent) => void; }) { // The main thread's key is its session's id. @@ -345,12 +362,24 @@ const SessionRow = memo(function SessionRow({ + +
{ + if (event.key !== "Escape" || event.defaultPrevented) return; + event.preventDefault(); + onBack(); + }} + className="min-h-0 flex-1 overflow-y-auto outline-none" + > + {SECTIONS.map((Section, index) => ( +
+
+
+ ))} +
+ + ); +} diff --git a/apps/web/src/widgets/session/view.tsx b/apps/web/src/widgets/session/view.tsx index 369d365c..09c1637e 100644 --- a/apps/web/src/widgets/session/view.tsx +++ b/apps/web/src/widgets/session/view.tsx @@ -14,9 +14,10 @@ import { threadAddress, threadRef } from "./address"; import { SessionComposer } from "./composer"; import { useFollow } from "./follow"; import { Markdown } from "./markdown"; -import { menuThreads } from "./menu"; +import { menuThreads, useSettings } from "./menu"; import { Message } from "./message"; import { Queue } from "./queue"; +import { ProjectSettings } from "./settings"; import { type Handoff, handoffs } from "./handoff"; import { type Display, displayLabel, usePendingPrompts, useRunDisplay, useSent } from "./display"; import { useNowSecond } from "../../hooks/now"; @@ -47,8 +48,17 @@ export function Session({ address, instanceId }: WidgetProps) { const [shown, setShown] = useState<{ readonly key: string; readonly previous?: string }>({ key }); if (shown.key !== key) setShown({ key, previous: shown.key }); useAtomMount(menuThreads); + const [settings, setSettings] = useSettings(instanceId); if (thread === undefined) return

Not a session: {address}

; - return ; + // The thread stays mounted under its project's settings, so it comes back as it was left. + return ( + <> + + {settings && setSettings(false)} />} + + ); } type State = @@ -498,7 +508,13 @@ function Conversation({ const project = state.status === "ready" ? state.info.project : listed?.project; const projectName = project?.name; + const [settings] = useSettings(instanceId); useEffect(() => { + if (settings) { + setTitle("Settings", projectName); + setTab(projectName === undefined ? "Settings" : `${projectName} / Settings`); + return; + } if (title === undefined) return; // The project leads, so sessions from different projects tell apart at a glance. setTitle(title, projectName); @@ -507,7 +523,7 @@ function Conversation({ if (projectName !== undefined) setIcon(); // While focused, the workspace's tab reads as this session: its project, then its title. setTab(projectName === undefined ? title : `${projectName} / ${title}`); - }, [title, projectName, setTitle, setIcon, setTab]); + }, [settings, title, projectName, setTitle, setIcon, setTab]); // Models that can't be listed first: a state that clears itself, so it can't be dismissed. Then a // refused send, else why the last run failed; dismissing hides that one notice only. diff --git a/apps/web/src/wm/desktop.tsx b/apps/web/src/wm/desktop.tsx index a1f40536..44fa259b 100644 --- a/apps/web/src/wm/desktop.tsx +++ b/apps/web/src/wm/desktop.tsx @@ -466,7 +466,11 @@ export function Desktop({ workspace }: DesktopProps) { if (destination !== undefined) move(widget.id, destination); }} onMoveNew={workspaces.length < MAX_WORKSPACES ? () => void moveNew(widget.id) : undefined} - menu={instance.kind === session.kind ? : undefined} + menu={ + instance.kind === session.kind ? ( + + ) : undefined + } onClose={() => close(widget.id)} onDragStart={() => setDrag({ widgetId: widget.id, deltaX: 0, deltaY: 0, target: null })} onDragMove={({ clientX, clientY, deltaX, deltaY }) => From a72788e3f0e9880d75294862fa694f3eb659b428 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Wed, 11 Nov 2026 10:20:43 +0530 Subject: [PATCH 3/4] feat(web): project banner with an editable name in project settings - Project settings open on a banner: the project's folder tile, its name as the page title, and its folder and machine. The name is a field: type, then Enter or leave it to save; Esc puts it back. The new name shows until the rename syncs back. - The placeholder sections leave the page; features return one by one. - projects.rename forwards to CodeWork's project.rename through the app server; the widget contract mirrors the call. - The frame header takes the project name from the list, so it follows a rename instead of keeping the name the thread opened with. Co-Authored-By: Claude Opus 5.5 --- apps/contract/src/contract.ts | 11 +++ apps/server/src/handlers.ts | 18 ++++- apps/web/src/widgets/session/settings.tsx | 88 +++++++++++++++++++---- apps/web/src/widgets/session/view.tsx | 5 +- apps/widget/src/codework.ts | 10 +++ 5 files changed, 114 insertions(+), 18 deletions(-) diff --git a/apps/contract/src/contract.ts b/apps/contract/src/contract.ts index 8a800782..ced0939f 100644 --- a/apps/contract/src/contract.ts +++ b/apps/contract/src/contract.ts @@ -82,6 +82,11 @@ export class HostError extends Schema.TaggedError()("Server.HostError message: Schema.String, }) {} +/** A project operation that failed, such as renaming one the server no longer has. */ +export class ProjectError extends Schema.TaggedError()("Server.ProjectError", { + message: Schema.String, +}) {} + export { Clone, ClonePhase, CloneStage, Folder, Repository }; /** The models could not be listed: a settings file or the model catalog, at `path`, is broken. */ @@ -477,6 +482,12 @@ export const Api = RpcGroup.make( success: Schema.Struct({ path: Schema.String, commitError: Schema.optionalKey(Schema.String) }), error: Schema.Union([CodeworkUnavailable, HostError]), }), + /** Renames a project on the CodeWork server; the sidebar follows once the change syncs back. */ + Rpc.make("projects.rename", { + payload: { projectId: Schema.String, name: Schema.Trimmed.check(Schema.isNonEmpty()) }, + success: Schema.Struct({ id: Schema.String, name: Schema.String }), + error: Schema.Union([CodeworkUnavailable, HostError, ProjectError]), + }), /** An existing folder as an absolute path; a missing one is created. */ Rpc.make("host.add", { payload: { path: Schema.String }, diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 70108f90..16db86b7 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -2,7 +2,14 @@ import { Effect, Stream } from "effect"; import { SqlClient } from "effect/sql"; import { Cli } from "./cli.ts"; import { Codework } from "./codework.ts"; -import { type CliAction, Contract, ModelsError, type ServerAdd, type ServerUpdate } from "@codeworksh/contract"; +import { + type CliAction, + Contract, + ModelsError, + ProjectError, + type ServerAdd, + type ServerUpdate, +} from "@codeworksh/contract"; import { Desk } from "./desk.ts"; import { Event } from "./event.ts"; import { Health } from "./health.ts"; @@ -58,6 +65,15 @@ export const layer = Contract.Api.toLayer( "host.create": Effect.fn("host.create")(function* ({ name }) { return yield* callHost((client) => client("host.create", { name }), { timeout: "60 seconds" }); }), + "projects.rename": Effect.fn("projects.rename")(function* ({ projectId, name }) { + return yield* callHost((client) => + client("project.rename", { projectId, name }).pipe( + Effect.catchTag("ProjectNotFoundError", () => + Effect.fail(new ProjectError({ message: "This project is no longer on its server." })), + ), + ), + ); + }), "host.add": Effect.fn("host.add")(function* ({ path }) { return yield* callHost((client) => client("host.add", { path })); }), diff --git a/apps/web/src/widgets/session/settings.tsx b/apps/web/src/widgets/session/settings.tsx index 6fbafc3b..72d34257 100644 --- a/apps/web/src/widgets/session/settings.tsx +++ b/apps/web/src/widgets/session/settings.tsx @@ -1,17 +1,21 @@ -import { useEffect, useRef } from "react"; +import type { SidebarProject } from "@codeworksh/contract"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/reactivity"; +import { useEffect, useRef, useState } from "react"; -import { ArrowLeft } from "@/components/vuesax"; -import { McpSettings } from "../../settings/mcp"; -import { ModelsSettings } from "../../settings/models"; -import { PluginsSettings } from "../../settings/plugins"; -import { SandboxesSettings } from "../../settings/sandboxes"; -import { SkillsSettings } from "../../settings/skills"; +import { ArrowLeft, Folder } from "@/components/vuesax"; +import { Server } from "../../kernel/rpc"; +import { failed } from "../threads/common"; +import { projectsAtom } from "./projects"; -/** The sections a project has, one page after another. */ -const SECTIONS = [ModelsSettings, McpSettings, SkillsSettings, PluginsSettings, SandboxesSettings]; +const renameProject = Server.mutation("projects.rename"); /** A session's project settings in place of its thread; Esc or the back row returns to the thread. */ -export function ProjectSettings({ onBack }: { readonly onBack: () => void }) { +export function ProjectSettings({ sessionId, onBack }: { readonly sessionId: string; readonly onBack: () => void }) { + const result = useAtomValue(projectsAtom); + const project = AsyncResult.isSuccess(result) + ? result.value.find((candidate) => candidate.sessions.some(({ id }) => id === sessionId)) + : undefined; const scroller = useRef(null); // Takes focus as it opens, so Esc returns from this frame only. useEffect(() => scroller.current?.focus({ preventScroll: true }), []); @@ -37,12 +41,66 @@ export function ProjectSettings({ onBack }: { readonly onBack: () => void }) { }} className="min-h-0 flex-1 overflow-y-auto outline-none" > - {SECTIONS.map((Section, index) => ( -
-
-
- ))} +
+ {project !== undefined && } +
); } + +/** The project as a tile, its name as the page's title and where it lives. The name is a field: type, then Enter or leave it. */ +function Banner({ project }: { readonly project: SidebarProject }) { + const rename = useAtomSet(renameProject, { mode: "promise" }); + // What is typed, until it is saved or put back. + const [draft, setDraft] = useState(); + // The saved name, shown until the rename syncs back to the list. + const [pending, setPending] = useState(); + if (pending !== undefined && project.name === pending) setPending(undefined); + const name = pending ?? project.name; + const commit = (value: string) => { + const next = value.trim(); + setDraft(undefined); + if (next === "" || next === name) return; + setPending(next); + void rename({ payload: { projectId: project.id, name: next } }).catch((error: unknown) => { + setPending(undefined); + failed(error); + }); + }; + return ( +
+ + + +
+ setDraft(event.target.value)} + onBlur={(event) => commit(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + // Puts the saved name back; the settings stay open. + event.preventDefault(); + setDraft(undefined); + event.currentTarget.value = name; + event.currentTarget.blur(); + } + }} + className="-mx-1.5 h-8 w-full min-w-0 truncate rounded-md bg-transparent px-1.5 text-[22px]/8 font-semibold tracking-tight outline-none transition-colors duration-150 ease-out hover:bg-ink/4 focus:bg-ink/4" + /> +
+ {/* From its end: the last parts tell projects apart, not the home directory. */} + + {project.path} + + · + {project.machine.name} +
+
+
+ ); +} diff --git a/apps/web/src/widgets/session/view.tsx b/apps/web/src/widgets/session/view.tsx index 09c1637e..27c87b91 100644 --- a/apps/web/src/widgets/session/view.tsx +++ b/apps/web/src/widgets/session/view.tsx @@ -56,7 +56,7 @@ export function Session({ address, instanceId }: WidgetProps) { - {settings && setSettings(false)} />} + {settings && setSettings(false)} />} ); } @@ -505,7 +505,8 @@ function Conversation({ // Named from the sessions list at once, so the frame never shows its default title while loading. const listed = useListedSession(thread.sessionId); const title = state.status === "ready" ? state.info.title : listed?.title; - const project = state.status === "ready" ? state.info.project : listed?.project; + // The list follows a rename; the loaded thread keeps the name it was opened with. + const project = listed?.project ?? (state.status === "ready" ? state.info.project : undefined); const projectName = project?.name; const [settings] = useSettings(instanceId); diff --git a/apps/widget/src/codework.ts b/apps/widget/src/codework.ts index bcaf4776..c1f8a31d 100644 --- a/apps/widget/src/codework.ts +++ b/apps/widget/src/codework.ts @@ -373,6 +373,10 @@ export const RuntimeConfig = Schema.Struct({ export type RuntimeConfig = typeof RuntimeConfig.Type; /** A host folder operation that failed: unreadable, not a directory, taken, or git/gh refused. */ +export class ProjectNotFoundError extends Schema.TaggedError()("ProjectNotFoundError", { + projectId: Schema.String, +}) {} + export class HostError extends Schema.TaggedError()("Server.HostError", { message: Schema.String, }) {} @@ -522,6 +526,12 @@ export const Api = RpcGroup.make( success: ThreadInfo, error: CreateError, }), + /** Renames a project. Trimmed, non-empty. */ + Rpc.make("project.rename", { + payload: { projectId: Schema.String, name: Schema.String }, + success: Schema.Struct({ id: Schema.String, name: Schema.String }), + error: ProjectNotFoundError, + }), /** The models a session anchored at `hostDir` can use; without it, only the user's settings apply. */ Rpc.make("model.list", { payload: { hostDir: Schema.optionalKey(Schema.String) }, From f0c82a532838e513bf111f385a875b5d2ed97e93 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Wed, 11 Nov 2026 11:36:08 +0530 Subject: [PATCH 4/4] feat(web): Repositories section with the project's remote URL - Project settings gain a Repositories section: the remote URL the project was cloned from, with a copy button, and its main checkout. Headings and rows follow Linear's settings type and spacing, a step quieter; the banner shows the project's folder only. - Switching threads keeps the page whole: it waits for the next project's directories and remote before swapping in one render. - project.remote in CodeWork reads origin of the project's main checkout on the host, with credentials taken out of http(s) URLs; null for a folder, a sandbox or no origin. The e2e test covers it. - projects.remote and projects.spaces (directories from the synced space table) through the app server. Co-Authored-By: Claude Opus 5.5 --- apps/contract/src/contract.ts | 22 +++ apps/server/src/handlers.ts | 10 ++ apps/server/src/reads.ts | 17 +++ apps/web/src/widgets/session/projects.ts | 22 +++ apps/web/src/widgets/session/settings.tsx | 131 ++++++++++++++++-- apps/widget/src/codework.ts | 6 + packages/codework/src/server/contract.ts | 6 + packages/codework/src/server/handlers.ts | 5 + packages/codework/src/server/host.ts | 22 ++- .../__artifacts__/server.project-name.json | 9 +- packages/codework/test/server.test.ts | 28 ++++ packages/harness/src/project/project.ts | 15 ++ 12 files changed, 276 insertions(+), 17 deletions(-) diff --git a/apps/contract/src/contract.ts b/apps/contract/src/contract.ts index ced0939f..a43d6ff0 100644 --- a/apps/contract/src/contract.ts +++ b/apps/contract/src/contract.ts @@ -77,6 +77,17 @@ export const SidebarProject = Schema.Struct({ }); export type SidebarProject = typeof SidebarProject.Type; +/** One of a project's directories: its main checkout, a worktree, a copy or a plain folder. */ +export const ProjectSpace = Schema.Struct({ + id: Schema.String, + location: Schema.String, + kind: SpaceInfo.fields.kind, + /** The sandbox it is in; null for the host. */ + env: Schema.NullOr(Schema.String), + status: SpaceInfo.fields.status, +}); +export type ProjectSpace = typeof ProjectSpace.Type; + /** A host folder operation that failed: unreadable, not a directory, taken, or git/gh refused. */ export class HostError extends Schema.TaggedError()("Server.HostError", { message: Schema.String, @@ -444,6 +455,11 @@ export const Api = RpcGroup.make( * An organization's active projects, newest activity first, each with its sessions that are not * deleted: on the default machine only, or on every machine. Read from app.db alone. */ + /** A project's directories, its main checkout first; read from app.db alone. */ + Rpc.make("projects.spaces", { + payload: { machineId: Schema.String, projectId: Schema.String }, + success: Schema.Array(ProjectSpace), + }), Rpc.make("sidebar.list", { payload: { organizationId: Schema.String, machines: Schema.Literals(["default", "all"]) }, success: Schema.Array(SidebarProject), @@ -482,6 +498,12 @@ export const Api = RpcGroup.make( success: Schema.Struct({ path: Schema.String, commitError: Schema.optionalKey(Schema.String) }), error: Schema.Union([CodeworkUnavailable, HostError]), }), + /** The `origin` URL of a project's main checkout, credentials taken out; null when it has none. */ + Rpc.make("projects.remote", { + payload: { projectId: Schema.String }, + success: Schema.Struct({ url: Schema.NullOr(Schema.String) }), + error: Schema.Union([CodeworkUnavailable, HostError, ProjectError]), + }), /** Renames a project on the CodeWork server; the sidebar follows once the change syncs back. */ Rpc.make("projects.rename", { payload: { projectId: Schema.String, name: Schema.Trimmed.check(Schema.isNonEmpty()) }, diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 16db86b7..6e372990 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -52,6 +52,7 @@ export const layer = Contract.Api.toLayer( ...tracking, "sidebar.list": (input) => Reads.sidebar(input).pipe(Effect.provideService(SqlClient.SqlClient, sql)), + "projects.spaces": (input) => Reads.spaces(input).pipe(Effect.provideService(SqlClient.SqlClient, sql)), "threads.list": (input) => Reads.threads(input).pipe(Effect.provideService(SqlClient.SqlClient, sql)), "threads.line": (input) => Reads.line(input).pipe(Effect.provideService(SqlClient.SqlClient, sql)), @@ -65,6 +66,15 @@ export const layer = Contract.Api.toLayer( "host.create": Effect.fn("host.create")(function* ({ name }) { return yield* callHost((client) => client("host.create", { name }), { timeout: "60 seconds" }); }), + "projects.remote": Effect.fn("projects.remote")(function* ({ projectId }) { + return yield* callHost((client) => + client("project.remote", { projectId }).pipe( + Effect.catchTag("ProjectNotFoundError", () => + Effect.fail(new ProjectError({ message: "This project is no longer on its server." })), + ), + ), + ); + }), "projects.rename": Effect.fn("projects.rename")(function* ({ projectId, name }) { return yield* callHost((client) => client("project.rename", { projectId, name }).pipe( diff --git a/apps/server/src/reads.ts b/apps/server/src/reads.ts index 4dae22f3..57323e5d 100644 --- a/apps/server/src/reads.ts +++ b/apps/server/src/reads.ts @@ -9,6 +9,7 @@ import { type LineRequest, LineThread, type Priority, + type ProjectSpace, type SidebarProject, type SidebarSession, SyncError, @@ -428,4 +429,20 @@ export const line = Effect.fn("Reads.line")(function* (request: LineRequest) { }).pipe(Effect.orDie); }); +/** A project's directories, its main checkout first, then by folder. */ +export const spaces = Effect.fn("Reads.spaces")(function* ({ + machineId, + projectId, +}: { + readonly machineId: string; + readonly projectId: string; +}) { + const sql = yield* SqlClient.SqlClient; + return yield* sql` + SELECT id, location, kind, env, status FROM space + WHERE machine_id = ${machineId} AND project_id = ${projectId} + ORDER BY kind <> 'primary', location + `; +}, Effect.orDie); + export * as Reads from "./reads.ts"; diff --git a/apps/web/src/widgets/session/projects.ts b/apps/web/src/widgets/session/projects.ts index 289136ce..55eeb3db 100644 --- a/apps/web/src/widgets/session/projects.ts +++ b/apps/web/src/widgets/session/projects.ts @@ -97,6 +97,28 @@ export const projectsAtom = Server.runtime ) .pipe(Atom.keepAlive); +/** A project's directories, by `/`: read when subscribed, and again after their sync changes. */ +export const spacesAtom = Atom.family((key: string) => { + const [machineId = "", projectId = ""] = key.split("/"); + return Server.runtime.atom( + Stream.unwrap( + Server.use((client) => + Effect.succeed( + client("event.subscribe", { topics: [syncTopics.changed] }).pipe( + Stream.filter( + (item) => + "_tag" in item || + Option.exists(decodeChanged(item.payload), (notice) => notice.space.length > 0), + ), + Stream.debounce("50 millis"), + Stream.mapEffect(() => client("projects.spaces", { machineId, projectId })), + ), + ), + ), + ).pipe(Stream.retry(Schedule.spaced("1 second"))), + ); +}); + /** Each session's main thread's run state, as synced. */ const mainRunStatesAtom = Atom.make((get) => { const result = get(projectsAtom); diff --git a/apps/web/src/widgets/session/settings.tsx b/apps/web/src/widgets/session/settings.tsx index 72d34257..3fee828b 100644 --- a/apps/web/src/widgets/session/settings.tsx +++ b/apps/web/src/widgets/session/settings.tsx @@ -1,14 +1,16 @@ -import type { SidebarProject } from "@codeworksh/contract"; +import type { ProjectSpace, SidebarProject } from "@codeworksh/contract"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/reactivity"; import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; -import { ArrowLeft, Folder } from "@/components/vuesax"; +import { ArrowLeft, Check, Copy, Folder, FolderGit2 } from "@/components/vuesax"; import { Server } from "../../kernel/rpc"; import { failed } from "../threads/common"; -import { projectsAtom } from "./projects"; +import { projectsAtom, spacesAtom } from "./projects"; const renameProject = Server.mutation("projects.rename"); +const remoteOf = (projectId: string) => Server.query("projects.remote", { projectId }); /** A session's project settings in place of its thread; Esc or the back row returns to the thread. */ export function ProjectSettings({ sessionId, onBack }: { readonly sessionId: string; readonly onBack: () => void }) { @@ -41,15 +43,46 @@ export function ProjectSettings({ sessionId, onBack }: { readonly sessionId: str }} className="min-h-0 flex-1 overflow-y-auto outline-none" > -
- {project !== undefined && } +
+ {project !== undefined && }
); } -/** The project as a tile, its name as the page's title and where it lives. The name is a field: type, then Enter or leave it. */ +interface View { + readonly project: SidebarProject; + readonly main: ProjectSpace | undefined; + /** Null when the project has no remote, or it could not be read. */ + readonly url: string | null; +} + +/** + * The page for `project`, drawn once its directories and remote are both in. Until then the page last + * drawn stays, whole: switching threads never shows one project's banner over a section still loading. + */ +function Page({ project }: { readonly project: SidebarProject }) { + const spaces = useAtomValue(spacesAtom(`${project.machine.id}/${project.id}`)); + const remote = useAtomValue(remoteOf(project.id)); + const settled = (result: AsyncResult.AsyncResult) => + AsyncResult.isSuccess(result) || AsyncResult.isFailure(result); + const main = AsyncResult.isSuccess(spaces) ? spaces.value.find((space) => space.kind === "primary") : undefined; + const url = AsyncResult.isSuccess(remote) ? remote.value.url : null; + const ready = settled(spaces) && settled(remote); + const [view, setView] = useState(); + if (ready && (view?.project !== project || view.main !== main || view.url !== url)) setView({ project, main, url }); + if (view === undefined) return null; + return ( + <> + {/* Keyed, so a name typed for one project never carries into the next. */} + + {view.main !== undefined && } + + ); +} + +/** The project as a tile, its name as the page's title and its folder. The name is a field: type, then Enter or leave it. */ function Banner({ project }: { readonly project: SidebarProject }) { const rename = useAtomSet(renameProject, { mode: "promise" }); // What is typed, until it is saved or put back. @@ -90,17 +123,85 @@ function Banner({ project }: { readonly project: SidebarProject }) { event.currentTarget.blur(); } }} - className="-mx-1.5 h-8 w-full min-w-0 truncate rounded-md bg-transparent px-1.5 text-[22px]/8 font-semibold tracking-tight outline-none transition-colors duration-150 ease-out hover:bg-ink/4 focus:bg-ink/4" + className="-mx-1.5 h-8 w-full min-w-0 truncate rounded-md bg-transparent px-1.5 text-[22px]/8 font-medium tracking-[-0.01em] outline-none transition-colors duration-150 ease-out hover:bg-ink/4 focus:bg-ink/4" /> -
- {/* From its end: the last parts tell projects apart, not the home directory. */} - - {project.path} - - · - {project.machine.name} -
+ {/* From its end: the last parts tell projects apart, not the home directory. */} + + {project.path} + ); } + +/** A section heading as Linear sets one, a step quieter: medium weight, in line with its rows' text. */ +function Heading({ children }: { readonly children: string }) { + return

{children}

; +} + +/** The project's repository: the URL it was cloned from, then its main checkout. */ +function Repository({ main, url }: { readonly main: ProjectSpace; readonly url: string | null }) { + return ( +
+ Repositories +
+
+
+ Remote URL + Where this repository was cloned from +
+ {url === null ? ( + No remote + ) : ( + // Text to read and copy, not a field: nothing here suggests it can be changed. +
+ + {url} + + +
+ )} +
+
+ +
+ {nameOf(main.location)} + {/* From its end: the last parts tell checkouts apart. */} + + {main.location} + +
+ Main +
+
+
+ ); +} + +/** Copies `text`, then shows a check for a moment so the copy is seen to have happened. */ +function CopyButton({ text }: { readonly text: string }) { + const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 1500); + return () => clearTimeout(timer); + }, [copied]); + return ( + + ); +} + +const nameOf = (location: string) => location.split("/").filter(Boolean).at(-1) ?? location; diff --git a/apps/widget/src/codework.ts b/apps/widget/src/codework.ts index c1f8a31d..60456cd8 100644 --- a/apps/widget/src/codework.ts +++ b/apps/widget/src/codework.ts @@ -526,6 +526,12 @@ export const Api = RpcGroup.make( success: ThreadInfo, error: CreateError, }), + /** The `origin` URL of a project's main checkout on the host, credentials taken out; null when there is none. */ + Rpc.make("project.remote", { + payload: { projectId: Schema.String }, + success: Schema.Struct({ url: Schema.NullOr(Schema.String) }), + error: ProjectNotFoundError, + }), /** Renames a project. Trimmed, non-empty. */ Rpc.make("project.rename", { payload: { projectId: Schema.String, name: Schema.String }, diff --git a/packages/codework/src/server/contract.ts b/packages/codework/src/server/contract.ts index c3db2716..3078147d 100644 --- a/packages/codework/src/server/contract.ts +++ b/packages/codework/src/server/contract.ts @@ -493,6 +493,12 @@ export const Api = RpcGroup.make( /** `HostError` when `hostDir` is a clone still running, failed or cancelled. */ error: Schema.Union([SessionErrors, SandboxErrors, LocationErrors, HostError]), }), + /** The `origin` URL of a project's main checkout on the host, credentials taken out; null when there is none. */ + Rpc.make("project.remote", { + payload: { projectId: Schema.String }, + success: Schema.Struct({ url: Schema.NullOr(Schema.String) }), + error: Project.ProjectNotFoundError, + }), Rpc.make("project.rename", { payload: { projectId: Schema.String, name: ProjectName }, success: Schema.Struct({ id: Schema.String, name: Schema.String }), diff --git a/packages/codework/src/server/handlers.ts b/packages/codework/src/server/handlers.ts index 42777b52..abaee83c 100644 --- a/packages/codework/src/server/handlers.ts +++ b/packages/codework/src/server/handlers.ts @@ -308,6 +308,11 @@ export const layer = Contract.Api.toLayer( }, }; }), + "project.remote": Effect.fnUntraced(function* ({ projectId }) { + const checkout = yield* Project.mainCheckout(projectId); + const url = checkout === undefined ? undefined : yield* host.remote(checkout); + return { url: url ?? null }; + }), "project.rename": Effect.fnUntraced(function* ({ projectId, name }) { const renamed = yield* Project.rename({ projectId, name }); return { id: renamed.id, name: renamed.name }; diff --git a/packages/codework/src/server/host.ts b/packages/codework/src/server/host.ts index 3cf72d6d..3fcf22b8 100644 --- a/packages/codework/src/server/host.ts +++ b/packages/codework/src/server/host.ts @@ -26,6 +26,8 @@ interface Interface { ) => Effect.Effect<{ readonly path: string; readonly commitError?: string }, Contract.HostError>; readonly status: () => Effect.Effect; readonly lookup: (repository: string) => Effect.Effect; + /** The `origin` URL of the checkout at `directory`, with any credentials taken out; none without one. */ + readonly remote: (directory: string) => Effect.Effect; } export class Service extends Context.Service()("@codeworksh/cli/server/host/Service") {} @@ -113,6 +115,19 @@ export const parseRepository = (selector: string): Selector | undefined => { return parts.length === 3 && host !== undefined && isGitHubHost(host) ? named(host, second, third) : undefined; }; +/** + * A remote URL safe to show: an http(s) URL loses its user and password, which may be a token + * (`https://x-access-token:...@github.com/...`). SSH and local forms carry none and pass as they are. + */ +export const shownRemote = (url: string) => { + if (!/^https?:\/\//i.test(url)) return url; + const parsed = URL.parse(url); + if (parsed === null) return url; + parsed.username = ""; + parsed.password = ""; + return parsed.toString(); +}; + const decodeRepository = Schema.decodeUnknownEffect( Schema.fromJsonString(Schema.Struct({ full_name: Schema.String, html_url: Schema.String, ssh_url: Schema.String })), ); @@ -280,7 +295,12 @@ const make = Effect.gen(function* () { return { nameWithOwner: found.full_name, url: found.html_url, sshUrl: found.ssh_url }; }); - return Service.of({ info, browse, add, create, status, lookup }); + const remote = Effect.fn("Host.remote")(function* (directory: string) { + const url = yield* git.remote(directory); + return url === undefined ? undefined : shownRemote(url); + }); + + return Service.of({ info, browse, add, create, status, lookup, remote }); }); // Git runs on the host; every call names its own directory, so the mount root is never read. diff --git a/packages/codework/test/__artifacts__/server.project-name.json b/packages/codework/test/__artifacts__/server.project-name.json index e9adfa52..58996afb 100644 --- a/packages/codework/test/__artifacts__/server.project-name.json +++ b/packages/codework/test/__artifacts__/server.project-name.json @@ -42,5 +42,12 @@ "rename": "rejected" } }, - "sessionsCreatedByInvalid": 0 + "sessionsCreatedByInvalid": 0, + "remote": { + "repo": "https://github.com/org/named-upstream", + "tokened": "https://github.com/org/tokened", + "folder": null, + "memory": null, + "missing": "ProjectNotFoundError" + } } diff --git a/packages/codework/test/server.test.ts b/packages/codework/test/server.test.ts index 6d96ac7a..69427d09 100644 --- a/packages/codework/test/server.test.ts +++ b/packages/codework/test/server.test.ts @@ -500,6 +500,26 @@ describe("server", () => { const after = (yield* rpc["thread.list"]({})).length; expect(after).toBe(before); + // 7. The origin URL of the main checkout on the host: credentials never leave the server. + const tokened = yield* Effect.promise(async () => { + const path = await repo(dir("tokened"), "tokened"); + await git(path, "remote", "set-url", "origin", "https://x-access-token:secret@github.com/org/tokened"); + return path; + }); + const tokenedSession = yield* rpc["session.create"]({ directory: tokened }); + const remoteOf = (sessionId: Session.SessionSchema.ID) => + projectOf(sessionId).pipe( + Effect.flatMap((project) => rpc["project.remote"]({ projectId: project?.id ?? "" })), + Effect.map(({ url }) => url), + ); + const remote = { + repo: yield* remoteOf(repoNamed.session.id), + tokened: yield* remoteOf(tokenedSession.session.id), + folder: yield* remoteOf(folderNamed.session.id), + memory: yield* remoteOf(memoryNamed.session.id), + missing: (yield* rpc["project.remote"]({ projectId: "prj_missing" }).pipe(Effect.flip))._tag, + }; + const artifact = { memory: { named: yield* nameOf(memoryNamed.session.id), unnamed: yield* nameOf(memoryUnnamed.session.id) }, folder: { named: yield* nameOf(folderNamed.session.id), unnamed: yield* nameOf(folderUnnamed.session.id) }, @@ -517,6 +537,7 @@ describe("server", () => { }, invalid, sessionsCreatedByInvalid: after - before, + remote, }; expect(artifact.memory).toEqual({ named: "In Memory", unnamed: "" }); expect(artifact.folder).toEqual({ named: "Plain", unnamed: "plain-unnamed" }); @@ -525,6 +546,13 @@ describe("server", () => { expect(artifact.rename).toMatchObject({ first: "C", second: "C", result: { sameId: true, name: "C" } }); for (const outcome of Object.values(invalid)) expect(outcome).toEqual({ create: "rejected", rename: "rejected" }); + expect(artifact.remote).toEqual({ + repo: "https://github.com/org/named-upstream", + tokened: "https://github.com/org/tokened", + folder: null, + memory: null, + missing: "ProjectNotFoundError", + }); return artifact; }) .pipe(Effect.scoped, Effect.provide(layer()), Effect.runPromise) diff --git a/packages/harness/src/project/project.ts b/packages/harness/src/project/project.ts index 689a7b76..0b36417b 100644 --- a/packages/harness/src/project/project.ts +++ b/packages/harness/src/project/project.ts @@ -260,6 +260,21 @@ export const rename = Effect.fn("Project.rename")(function* (input: { }); }); +/** + * A project's main checkout on the host, if it has an active one. Needs only the database; a + * checkout inside a sandbox has no host path to run git in. + */ +export const mainCheckout = Effect.fn("Project.mainCheckout")(function* (projectId: string) { + const sql = yield* SqlClient.SqlClient; + const found = yield* sql<{ id: string }>`SELECT id FROM project WHERE id = ${projectId}`.pipe(Effect.orDie); + if (found.length === 0) return yield* new ProjectNotFoundError({ projectId }); + const rows = yield* sql<{ location: string }>` + SELECT location FROM space + WHERE project_id = ${projectId} AND env IS NULL AND kind = 'primary' AND status = 'active' + `.pipe(Effect.orDie); + return rows[0]?.location; +}); + /** Takes an assembled sandbox — local or remote — not a local backend. */ export const layerWith = (sandbox: Sandbox.Sandbox) => layer.pipe(