diff --git a/CHANGELOG.md b/CHANGELOG.md index a70265bb..62b6fa50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,59 @@ This file is the canonical source for unreleased changes and published release n ## Format -- Add in-progress work under `## [Unreleased] +- Add in-progress work under `## [Unreleased]`. + +## [Unreleased] ### Added - 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". - 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. +### @codeworksh/harness + +#### Added + +- Prompts queued while the agent works can be read and changed before it takes them in. `Control.pending` lists them, steers and follow-ups, in the order they were sent. `Control.cancel` withdraws one, recorded as a `session.prompt.cancelled` event; it returns `false` when the prompt was already taken in, and a cancelled message id may be sent again. `Control.update` moves one to the other lane or rewrites its text, keeping its place, recorded as a `session.prompt.updated` event; moving it to the steer lane also wakes the session, so a prompt left queued by a stop runs now. +- A run cut off by a crash or a shutdown resumes when the server starts again (`Control.resumeUnfinished`). A crash counts as a restart and a shutdown does not; a committed turn starts the count over. After 5 restarts (`Control.RESTART_LIMIT`) the session is parked instead of resumed: `Session.Info.parked` is `true`, and its next prompt runs it again. A resumed run answers the reply it was cut off in before a queued follow-up goes in; steers join it at once. A run the user stopped is not resumed, and what was queued behind it stays queued. + +#### Fixed + +- A turn started for a prompt that was cancelled just before it joined the conversation no longer asks the model anything. +- A prompt changed, cancelled or sent again while it was being taken in no longer fails the run; the run takes in the prompt as it now stands. + +#### Internal + +- Migrations add `session_input.cancelled_seq`, and `session.execution` and `session.restarts`. +- Tests use `gpt-6-luna` in place of the deprecated `gpt-5.5`. + +### @codeworksh/cli + +#### Added + +- Added the `session.queue` RPC (`{ sessionId }`): the prompts sent while the agent works and not yet taken in, steers and follow-ups, in the order they were sent. +- Added the `session.cancel` RPC (`{ sessionId, messageId }`), which withdraws a queued prompt. `cancelled` is `false` when the prompt was already taken in. +- Added the `session.update` RPC (`{ sessionId, messageId, delivery?, text? }`), which moves a queued prompt to the other lane or rewrites its text, keeping its place. `delivery: "steer"` also wakes the session. `updated` is `false` when the prompt was already taken in. +- `codework serve` resumes the sessions a crash or a shutdown cut off when it starts. Session info carries `parked: true` for a session that restarted too often to resume on its own; its next prompt runs it again. + +#### Fixed + +- Ctrl+C on `codework run` without `--server` stops the run as a user stop, so `codework serve` does not resume it later. + +#### Internal + +- Tests use `gpt-6-luna` in place of the deprecated `gpt-5.5`. + +### @codeworksh/aikit + +#### Fixed + +- `modelgen` leaves out providers with no models instead of writing them as empty entries. + +#### Internal + +- `gpt-5.5` is out of the live OpenAI test models; tests use `gpt-6-luna`. + ## [@codeworksh/cli@0.4.0] - 2026-10-20 ### Added diff --git a/apps/contract/src/contract.ts b/apps/contract/src/contract.ts index 8cf728ed..85cf9fd7 100644 --- a/apps/contract/src/contract.ts +++ b/apps/contract/src/contract.ts @@ -46,27 +46,26 @@ export const Workspace = Schema.Struct({ bit: Schema.Number, pos: Schema.Number, name: Schema.String, - /** A name from the app's icon set, e.g. "code". */ - icon: Schema.String, layout: Layout, /** Master column share of the width, 0–1. */ mfact: Schema.Number, /** Created to show something the user opened (a session); removed once it shows nothing. */ auto: Schema.Boolean, + /** What its tab last read, as its focused widget set it; absent until a widget has. */ + tab: Schema.optionalKey(Schema.String), }); export type Workspace = typeof Workspace.Type; export const WorkspaceCreate = Schema.Struct({ name: Schema.String, - icon: Schema.String, auto: Schema.optionalKey(Schema.Boolean), }); export const WorkspaceUpdate = Schema.Struct({ id: Schema.String, name: Schema.optional(Schema.String), - icon: Schema.optional(Schema.String), layout: Schema.optional(Layout), mfact: Schema.optional(Schema.Number), + tab: Schema.optional(Schema.String), }); export const WorkspaceOrder = Schema.Struct({ ids: Schema.Array(Schema.String) }); export const WorkspaceId = Schema.Struct({ id: Schema.String }); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 5b5ca17d..db80c2a7 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -124,11 +124,16 @@ function createMainWindow(): BrowserWindow { }), // Windows and Linux take the window's icon; macOS ignores it for the Dock's. icon: ICON, + // Hidden until the page's first paint, the splash in the saved theme, so it never opens blank. + show: false, webPreferences: { preload: path.join(app.getAppPath(), "out/preload.cjs"), sandbox: true, }, }); + window.once("ready-to-show", () => window.show()); + // Linux can miss ready-to-show; the load finishing reveals the window there. + if (process.platform === "linux") window.webContents.once("did-finish-load", () => window.show()); void window.loadURL(APP_URL); return window; } diff --git a/apps/server/src/db.ts b/apps/server/src/db.ts index 0c8721d9..3f2ac840 100644 --- a/apps/server/src/db.ts +++ b/apps/server/src/db.ts @@ -109,6 +109,16 @@ const migrations = { })}`; yield* sql`DELETE FROM kv WHERE package = 'codework' AND key LIKE 'servers.local.%'`; }), + // Workspace tabs all show the window icon now. + "202610230001_drop_workspace_icon": Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`ALTER TABLE workspaces DROP COLUMN icon`; + }), + // What the workspace's tab last read, so a workspace not yet opened since a restart names what it shows. + "202610240001_workspace_tab": Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`ALTER TABLE workspaces ADD COLUMN tab TEXT`; + }), }; const setup = Effect.gen(function* () { diff --git a/apps/server/src/workspaces.ts b/apps/server/src/workspaces.ts index 0ab83c31..2f59d4a8 100644 --- a/apps/server/src/workspaces.ts +++ b/apps/server/src/workspaces.ts @@ -18,11 +18,17 @@ const fail = (message: string) => new Contract.WorkspaceError({ message }); export const handlers = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - // SQLite has no booleans; `auto` is stored as 0 or 1. - const list = sql & { auto: number }>` - SELECT id, bit, pos, name, icon, layout, mfact, auto FROM workspaces ORDER BY pos + // SQLite has no booleans; `auto` is stored as 0 or 1. A tab never set is NULL. + const list = sql & { auto: number; tab: string | null }>` + SELECT id, bit, pos, name, layout, mfact, auto, tab FROM workspaces ORDER BY pos `.pipe( - Effect.map((rows) => rows.map((row): Workspace => ({ ...row, auto: row.auto === 1 }))), + Effect.map((rows) => + rows.map(({ auto, tab, ...row }): Workspace => ({ + ...row, + auto: auto === 1, + ...(tab === null ? {} : { tab }), + })), + ), Effect.orDie, ); @@ -49,7 +55,6 @@ export const handlers = Effect.gen(function* () { "workspaces.create": Effect.fn("workspaces.create")(function* ({ name, - icon, auto = false, }: typeof WorkspaceCreate.Type) { return yield* sql @@ -64,7 +69,6 @@ export const handlers = Effect.gen(function* () { bit, pos: current.length, name, - icon, layout: "tile", mfact: 0.6, auto, diff --git a/apps/server/test/__artifacts__/desk.json b/apps/server/test/__artifacts__/desk.json index 460db1fc..9400b63f 100644 --- a/apps/server/test/__artifacts__/desk.json +++ b/apps/server/test/__artifacts__/desk.json @@ -9,7 +9,6 @@ "bit": 3, "pos": 3, "name": "Scratch", - "icon": "box", "layout": "tile", "mfact": 0.6, "auto": false @@ -147,7 +146,8 @@ { "name": "Kept", "auto": true, - "pos": 3 + "pos": 3, + "tab": "Kept / A session" } ], "desk": { diff --git a/apps/server/test/desk.test.ts b/apps/server/test/desk.test.ts index 3afce05b..46daf066 100644 --- a/apps/server/test/desk.test.ts +++ b/apps/server/test/desk.test.ts @@ -60,7 +60,7 @@ it("desk round-trips and follows workspace deletion", async () => { const rpc = yield* RpcClient.make(Contract.Api); const empty = yield* rpc["desk.get"](); - const workspace = yield* rpc["workspaces.create"]({ name: "Scratch", icon: "box" }); + const workspace = yield* rpc["workspaces.create"]({ name: "Scratch" }); const bit = 1 << workspace.bit; const saved: Contract.Desk = { instances: [ @@ -93,8 +93,10 @@ it("desk round-trips and follows workspace deletion", async () => { }).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 }); + const kept = yield* rpc["workspaces.create"]({ name: "Kept", auto: true }); + // What its tab last read, as its frame set it. + yield* rpc["workspaces.update"]({ id: kept.id, tab: "Kept / A session" }); + yield* rpc["workspaces.create"]({ name: "Stray", auto: true }); yield* rpc["desk.save"]({ instances: [{ id: "notes-2", kind: "notes", tags: 1 << kept.bit, props: {}, pinned: false }], tileOrder: ["notes-2"], @@ -121,9 +123,16 @@ it("desk round-trips and follows workspace deletion", async () => { 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 }; + return { workspaces: workspaces.map(({ name, auto, pos, tab }) => ({ name, auto, pos, tab })), desk }; }).pipe(Effect.scoped, Effect.provide(protocol()), Effect.runPromise); expect(afterRestart.workspaces.map((workspace) => workspace.name)).toEqual(["Main", "Code", "Notes", "Kept"]); + // A workspace's last tab survives the restart, so it names what it shows before its frame loads. + expect(afterRestart.workspaces.map((workspace) => workspace.tab)).toEqual([ + undefined, + undefined, + undefined, + "Kept / A session", + ]); await expect(`${JSON.stringify({ ...steps, afterRestart }, null, "\t")}\n`).toMatchFileSnapshot( "./__artifacts__/desk.json", diff --git a/apps/web/index.html b/apps/web/index.html index bc1ddb9a..e293351c 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -10,9 +10,63 @@ document.documentElement.dataset.theme = localStorage.getItem("codework-theme") ?? "tokyo-night"; } catch {} + + + -
+ +
+
+ CodeWork +
+
diff --git a/apps/web/src/bootstrap.ts b/apps/web/src/bootstrap.ts index 984bfe27..d1d67a73 100644 --- a/apps/web/src/bootstrap.ts +++ b/apps/web/src/bootstrap.ts @@ -1,4 +1,16 @@ // Separate entry so this module runs before the (heavier) React tree mounts. -void import("./main").catch((error: unknown) => { - console.error(error); -}); +void import("./main") + .then(({ startup }) => startup) + .catch((error: unknown) => { + console.error(error); + // The app never mounted, so the splash is still up: it turns into the failure and a way out. + const shell = document.getElementById("boot-shell"); + if (shell === null) return; + const message = document.createElement("p"); + message.textContent = "CodeWork could not load."; + const reload = document.createElement("button"); + reload.type = "button"; + reload.textContent = "Reload"; + reload.addEventListener("click", () => window.location.reload()); + shell.replaceChildren(message, reload); + }); diff --git a/apps/web/src/components/banner.tsx b/apps/web/src/components/banner.tsx index 839f60dc..693eb4bb 100644 --- a/apps/web/src/components/banner.tsx +++ b/apps/web/src/components/banner.tsx @@ -56,7 +56,7 @@ export function ComposerNotice({ return (
diff --git a/apps/web/src/components/composer.tsx b/apps/web/src/components/composer.tsx index 57bddc1d..5d86972d 100644 --- a/apps/web/src/components/composer.tsx +++ b/apps/web/src/components/composer.tsx @@ -1,5 +1,7 @@ -import type { ReactNode } from "react"; +import { CornerDownLeft, X, type LucideIcon } from "lucide-react"; +import type { ComponentProps, ReactNode, Ref } from "react"; +import { cn } from "@/lib/utils"; import { isMac } from "@codeworksh/widget/view"; /** How a send was asked for: `alternate` is Mod+Enter, which a caller may give its own meaning. */ @@ -22,6 +24,10 @@ export function Composer({ controls, actions, tray, + header, + inputRef, + onEscape, + className, }: { readonly value: string; readonly onChange: (value: string) => void; @@ -34,25 +40,42 @@ export function Composer({ readonly controls?: ReactNode; readonly actions: ReactNode; readonly tray?: ReactNode; + readonly header?: ReactNode; + readonly inputRef?: Ref; + readonly onEscape?: () => void; + readonly className?: string; }) { return (
{ event.preventDefault(); onSubmit({ alternate: false }); }} > -
+ {header} +