Skip to content

Commit e7df22a

Browse files
sanchitrkclaude
andauthored
Desktop overhaul: workspace tabs, settings, queued prompts, and resume after restart (#98)
* chore * tweaks * fix(web): unify desktop shell surfaces and tab alignment * fix(web): refine themes and split session surfaces * fix(web): soften frame drag indicators with theme shadows * feat(web): add themed startup splash Show the logo in the saved theme until the first route loads, turn it into a reload prompt if the app fails to load, and keep the desktop window hidden until its first paint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): add New session button to sidebar Starts on the start page in the current session's project; drops the model line from session rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ui: tweaks * wip: tweaks * wip: tweaks * feat(web): close workspace tabs and drop workspace icons - Workspace tabs show a close button on hover; closing deletes the workspace without a confirm and moves to a neighbouring tab. - Tabs always show the window icon; the workspace icon, its settings picker and the stored column are removed. - Session frames show the project's badge, as in the sessions sidebar; frame icons accept an element as well as a Lucide icon. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): integrate settings with desktop shell * feat(web): scroll the providers list and detail on their own Side by side, the providers card keeps a fixed height and each pane scrolls inside it; stacked, the page scrolls as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: use gpt-6-luna instead of the deprecated gpt-5.5 gpt-5.5 is out of the live OpenAI model list, its thinking-levels artifact and every stubbed test. The thinking wire test gives its gpt-6-luna model the catalog's thinking map, since GPT-6 takes no minimal effort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(harness): list, cancel and update queued prompts Prompts admitted but not yet promoted can now be read, withdrawn and changed before the agent takes them in: - session.queue lists them, both lanes, in admission order. - session.cancel withdraws one through a durable session.prompt.cancelled event. The row is kept and marked cancelled_seq, so a promotion already in flight loses rather than appending it; a cancelled id may be sent again. - session.update moves one to the other lane or rewrites it in place through session.prompt.updated, carrying only the changed fields. Moving to the steer lane wakes the session, so a prompt left by a stop runs now. A promotion that finds its prompt changed, cancelled or re-admitted since selection steps back instead of failing the drain, and a turn started for a prompt that was cancelled before promotion makes no provider request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): show queued steers and follow-ups above the session composer A strip tucked behind the composer lists the prompts waiting to join the conversation, while the agent works and after a stop or failure leaves them parked. Past three it shows a count; past four it scrolls. Each row can steer now (or send now while idle), move to the follow-up lane, be edited in place, go back to the composer, or be removed. The transcript stays at its end as the strip grows or shrinks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(harness): resume unfinished runs after a restart Like Pi, work cut off by a crash or a shutdown resumes when the server starts again. The session table records a run as unfinished (execution: running, or closed after a shutdown) and counts the restarts a crash causes; a committed turn starts the count over. - Control marks a session running before every wake, so an accepted prompt survives a crash before its run starts. - A run's end clears the mark: a finished run unless prompts are still pending, a user stop unless a prompt came after it. A shutdown leaves it closed. - At startup the codework server claims each marked session atomically, counting a restart for a crash, and resumes it. At five restarts the session is parked instead; session.info reports it, and its next prompt runs it again. A recovered run does nothing if a stop cleared the mark meanwhile, and answers the reply it was cut off in before a queued follow-up goes in. - Ctrl+C on a local `codework run` is a user stop, so it is not resumed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(aikit): type the deferred tools fixture as provider function tools With @types/json-schema reachable, the provider's JSONSchema7 is no longer any, and the inferred `type: string` schemas stop matching. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: lint the web app's Tailwind classes @shadcn/lint's no-unknown-classes and no-raw-colors gate vp check for apps/web, outside shadcn's components/ui. `vp run lint:tailwind` adds no-arbitrary-values, no-restyle, require-static-classes and Tailwind's canonical class names (eslint-plugin-better-tailwindcss) until each is clean enough to join them. Classes rewritten to their canonical names; the generated CSS is the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): use a text scale and theme radii instead of arbitrary values Like t3code, sizes below Tailwind's xs are theme tokens (text-2xs, 3xs, 4xs, with their line heights); 12px and 14px text uses text-xs and text-sm, 13px moves up to text-sm and 15px headings to text-base. Exact radii become rounded-sm/md/lg/2xl; the title bar's nested radii move to xl/lg/md and stay concentric. One-off shadows, gradients, the queue fade and inline code size are allow-listed in no-arbitrary-values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): share one prompt composer for new sessions and replies PromptComposer, ComposerHeader and ComposerSubmit give the start page's New Session box and a session's Reply box the same surface, header and submit button, instead of each building its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): one-line workspace tabs titled by the focused widget A widget's setTab now takes one string, such as "Project / Title", and the tab shortens it at its end to fit; the full text is in its tooltip. The session sets its project and title, so the tab names both again. A frame moved to a new workspace still names it for its project. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): keep each workspace tab's title across restarts A workspace stores what its tab last read (a new nullable tab column), saved through workspaces.update when its focused frame sets a new one. After a restart, a workspace not opened yet shows that title instead of only its name, until its frame sets a fresh one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(aikit): expect modelgen to leave out an empty Codex provider modelgen omits providers with no models, so a snapshot without OpenAI entries has no openai-codex entry at all rather than an empty one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: note unreleased harness, cli and aikit changes Queued-prompt list, cancel and update, resume after restart, the local Ctrl+C stop, and modelgen leaving out empty providers. Also restores the Unreleased heading the format note had swallowed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: keep reference projects out of code comments Comments say what the code does and why, without naming the projects it was compared against. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(tui): restore its comments as they were The previous commit's comment rewording was meant for the TypeScript packages only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2ad5fdc commit e7df22a

96 files changed

Lines changed: 3735 additions & 848 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,59 @@ This file is the canonical source for unreleased changes and published release n
44

55
## Format
66

7-
- Add in-progress work under `## [Unreleased]
7+
- Add in-progress work under `## [Unreleased]`.
8+
9+
## [Unreleased]
810

911
### Added
1012

1113
- 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".
1214
- 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.
1315

16+
### @codeworksh/harness
17+
18+
#### Added
19+
20+
- 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.
21+
- 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.
22+
23+
#### Fixed
24+
25+
- A turn started for a prompt that was cancelled just before it joined the conversation no longer asks the model anything.
26+
- 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.
27+
28+
#### Internal
29+
30+
- Migrations add `session_input.cancelled_seq`, and `session.execution` and `session.restarts`.
31+
- Tests use `gpt-6-luna` in place of the deprecated `gpt-5.5`.
32+
33+
### @codeworksh/cli
34+
35+
#### Added
36+
37+
- 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.
38+
- Added the `session.cancel` RPC (`{ sessionId, messageId }`), which withdraws a queued prompt. `cancelled` is `false` when the prompt was already taken in.
39+
- 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.
40+
- `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.
41+
42+
#### Fixed
43+
44+
- Ctrl+C on `codework run` without `--server` stops the run as a user stop, so `codework serve` does not resume it later.
45+
46+
#### Internal
47+
48+
- Tests use `gpt-6-luna` in place of the deprecated `gpt-5.5`.
49+
50+
### @codeworksh/aikit
51+
52+
#### Fixed
53+
54+
- `modelgen` leaves out providers with no models instead of writing them as empty entries.
55+
56+
#### Internal
57+
58+
- `gpt-5.5` is out of the live OpenAI test models; tests use `gpt-6-luna`.
59+
1460
## [@codeworksh/cli@0.4.0] - 2026-10-20
1561

1662
### Added

‎apps/contract/src/contract.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,27 +46,26 @@ export const Workspace = Schema.Struct({
4646
bit: Schema.Number,
4747
pos: Schema.Number,
4848
name: Schema.String,
49-
/** A name from the app's icon set, e.g. "code". */
50-
icon: Schema.String,
5149
layout: Layout,
5250
/** Master column share of the width, 0–1. */
5351
mfact: Schema.Number,
5452
/** Created to show something the user opened (a session); removed once it shows nothing. */
5553
auto: Schema.Boolean,
54+
/** What its tab last read, as its focused widget set it; absent until a widget has. */
55+
tab: Schema.optionalKey(Schema.String),
5656
});
5757
export type Workspace = typeof Workspace.Type;
5858

5959
export const WorkspaceCreate = Schema.Struct({
6060
name: Schema.String,
61-
icon: Schema.String,
6261
auto: Schema.optionalKey(Schema.Boolean),
6362
});
6463
export const WorkspaceUpdate = Schema.Struct({
6564
id: Schema.String,
6665
name: Schema.optional(Schema.String),
67-
icon: Schema.optional(Schema.String),
6866
layout: Schema.optional(Layout),
6967
mfact: Schema.optional(Schema.Number),
68+
tab: Schema.optional(Schema.String),
7069
});
7170
export const WorkspaceOrder = Schema.Struct({ ids: Schema.Array(Schema.String) });
7271
export const WorkspaceId = Schema.Struct({ id: Schema.String });

‎apps/desktop/src/main.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,16 @@ function createMainWindow(): BrowserWindow {
124124
}),
125125
// Windows and Linux take the window's icon; macOS ignores it for the Dock's.
126126
icon: ICON,
127+
// Hidden until the page's first paint, the splash in the saved theme, so it never opens blank.
128+
show: false,
127129
webPreferences: {
128130
preload: path.join(app.getAppPath(), "out/preload.cjs"),
129131
sandbox: true,
130132
},
131133
});
134+
window.once("ready-to-show", () => window.show());
135+
// Linux can miss ready-to-show; the load finishing reveals the window there.
136+
if (process.platform === "linux") window.webContents.once("did-finish-load", () => window.show());
132137
void window.loadURL(APP_URL);
133138
return window;
134139
}

‎apps/server/src/db.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,16 @@ const migrations = {
109109
})}`;
110110
yield* sql`DELETE FROM kv WHERE package = 'codework' AND key LIKE 'servers.local.%'`;
111111
}),
112+
// Workspace tabs all show the window icon now.
113+
"202610230001_drop_workspace_icon": Effect.gen(function* () {
114+
const sql = yield* SqlClient.SqlClient;
115+
yield* sql`ALTER TABLE workspaces DROP COLUMN icon`;
116+
}),
117+
// What the workspace's tab last read, so a workspace not yet opened since a restart names what it shows.
118+
"202610240001_workspace_tab": Effect.gen(function* () {
119+
const sql = yield* SqlClient.SqlClient;
120+
yield* sql`ALTER TABLE workspaces ADD COLUMN tab TEXT`;
121+
}),
112122
};
113123

114124
const setup = Effect.gen(function* () {

‎apps/server/src/workspaces.ts‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,17 @@ const fail = (message: string) => new Contract.WorkspaceError({ message });
1818
export const handlers = Effect.gen(function* () {
1919
const sql = yield* SqlClient.SqlClient;
2020

21-
// SQLite has no booleans; `auto` is stored as 0 or 1.
22-
const list = sql<Omit<Workspace, "auto"> & { auto: number }>`
23-
SELECT id, bit, pos, name, icon, layout, mfact, auto FROM workspaces ORDER BY pos
21+
// SQLite has no booleans; `auto` is stored as 0 or 1. A tab never set is NULL.
22+
const list = sql<Omit<Workspace, "auto" | "tab"> & { auto: number; tab: string | null }>`
23+
SELECT id, bit, pos, name, layout, mfact, auto, tab FROM workspaces ORDER BY pos
2424
`.pipe(
25-
Effect.map((rows) => rows.map((row): Workspace => ({ ...row, auto: row.auto === 1 }))),
25+
Effect.map((rows) =>
26+
rows.map(({ auto, tab, ...row }): Workspace => ({
27+
...row,
28+
auto: auto === 1,
29+
...(tab === null ? {} : { tab }),
30+
})),
31+
),
2632
Effect.orDie,
2733
);
2834

@@ -49,7 +55,6 @@ export const handlers = Effect.gen(function* () {
4955

5056
"workspaces.create": Effect.fn("workspaces.create")(function* ({
5157
name,
52-
icon,
5358
auto = false,
5459
}: typeof WorkspaceCreate.Type) {
5560
return yield* sql
@@ -64,7 +69,6 @@ export const handlers = Effect.gen(function* () {
6469
bit,
6570
pos: current.length,
6671
name,
67-
icon,
6872
layout: "tile",
6973
mfact: 0.6,
7074
auto,

‎apps/server/test/__artifacts__/desk.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
"bit": 3,
1010
"pos": 3,
1111
"name": "Scratch",
12-
"icon": "box",
1312
"layout": "tile",
1413
"mfact": 0.6,
1514
"auto": false
@@ -147,7 +146,8 @@
147146
{
148147
"name": "Kept",
149148
"auto": true,
150-
"pos": 3
149+
"pos": 3,
150+
"tab": "Kept / A session"
151151
}
152152
],
153153
"desk": {

‎apps/server/test/desk.test.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ it("desk round-trips and follows workspace deletion", async () => {
6060
const rpc = yield* RpcClient.make(Contract.Api);
6161
const empty = yield* rpc["desk.get"]();
6262

63-
const workspace = yield* rpc["workspaces.create"]({ name: "Scratch", icon: "box" });
63+
const workspace = yield* rpc["workspaces.create"]({ name: "Scratch" });
6464
const bit = 1 << workspace.bit;
6565
const saved: Contract.Desk = {
6666
instances: [
@@ -93,8 +93,10 @@ it("desk round-trips and follows workspace deletion", async () => {
9393
}).pipe(Effect.flip);
9494

9595
// Two auto workspaces: one keeps a frame, the other is left empty without being closed.
96-
const kept = yield* rpc["workspaces.create"]({ name: "Kept", icon: "box", auto: true });
97-
yield* rpc["workspaces.create"]({ name: "Stray", icon: "box", auto: true });
96+
const kept = yield* rpc["workspaces.create"]({ name: "Kept", auto: true });
97+
// What its tab last read, as its frame set it.
98+
yield* rpc["workspaces.update"]({ id: kept.id, tab: "Kept / A session" });
99+
yield* rpc["workspaces.create"]({ name: "Stray", auto: true });
98100
yield* rpc["desk.save"]({
99101
instances: [{ id: "notes-2", kind: "notes", tags: 1 << kept.bit, props: {}, pinned: false }],
100102
tileOrder: ["notes-2"],
@@ -121,9 +123,16 @@ it("desk round-trips and follows workspace deletion", async () => {
121123
const rpc = yield* RpcClient.make(Contract.Api);
122124
const workspaces = yield* rpc["workspaces.list"]();
123125
const desk = yield* rpc["desk.get"]();
124-
return { workspaces: workspaces.map(({ name, auto, pos }) => ({ name, auto, pos })), desk };
126+
return { workspaces: workspaces.map(({ name, auto, pos, tab }) => ({ name, auto, pos, tab })), desk };
125127
}).pipe(Effect.scoped, Effect.provide(protocol()), Effect.runPromise);
126128
expect(afterRestart.workspaces.map((workspace) => workspace.name)).toEqual(["Main", "Code", "Notes", "Kept"]);
129+
// A workspace's last tab survives the restart, so it names what it shows before its frame loads.
130+
expect(afterRestart.workspaces.map((workspace) => workspace.tab)).toEqual([
131+
undefined,
132+
undefined,
133+
undefined,
134+
"Kept / A session",
135+
]);
127136

128137
await expect(`${JSON.stringify({ ...steps, afterRestart }, null, "\t")}\n`).toMatchFileSnapshot(
129138
"./__artifacts__/desk.json",

‎apps/web/index.html‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,63 @@
1010
document.documentElement.dataset.theme = localStorage.getItem("codework-theme") ?? "tokyo-night";
1111
} catch {}
1212
</script>
13+
<!-- Linked here rather than imported by style.css, so the splash paints in the theme before the app's JS runs. -->
14+
<link rel="stylesheet" href="/src/themes.css" />
15+
<style>
16+
html,
17+
body,
18+
#root {
19+
height: 100%;
20+
margin: 0;
21+
}
22+
23+
/* The app's desktop tint (--cw-desktop in style.css), so the swap to the app does not shift colour. */
24+
body {
25+
background: color-mix(in oklab, var(--t-bg) 96%, var(--t-text));
26+
color: var(--t-text);
27+
font:
28+
13px/1.4 system-ui,
29+
-apple-system,
30+
"Segoe UI",
31+
sans-serif;
32+
}
33+
34+
#boot-shell {
35+
display: flex;
36+
height: 100%;
37+
flex-direction: column;
38+
align-items: center;
39+
justify-content: center;
40+
gap: 16px;
41+
-webkit-app-region: drag;
42+
user-select: none;
43+
}
44+
45+
#boot-shell img {
46+
width: 64px;
47+
height: 64px;
48+
border-radius: 14px;
49+
}
50+
51+
#boot-shell button {
52+
padding: 6px 14px;
53+
border: 1px solid currentColor;
54+
border-radius: 6px;
55+
background: transparent;
56+
color: inherit;
57+
font: inherit;
58+
cursor: pointer;
59+
-webkit-app-region: no-drag;
60+
}
61+
</style>
1362
</head>
1463
<body>
15-
<div id="root"></div>
64+
<!-- The splash: shown until the app mounts and replaces it. -->
65+
<div id="root">
66+
<div id="boot-shell">
67+
<img src="/src/assets/codework.png" alt="CodeWork" />
68+
</div>
69+
</div>
1670
<script type="module" src="/src/bootstrap.ts"></script>
1771
</body>
1872
</html>

‎apps/web/src/bootstrap.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
11
// Separate entry so this module runs before the (heavier) React tree mounts.
2-
void import("./main").catch((error: unknown) => {
3-
console.error(error);
4-
});
2+
void import("./main")
3+
.then(({ startup }) => startup)
4+
.catch((error: unknown) => {
5+
console.error(error);
6+
// The app never mounted, so the splash is still up: it turns into the failure and a way out.
7+
const shell = document.getElementById("boot-shell");
8+
if (shell === null) return;
9+
const message = document.createElement("p");
10+
message.textContent = "CodeWork could not load.";
11+
const reload = document.createElement("button");
12+
reload.type = "button";
13+
reload.textContent = "Reload";
14+
reload.addEventListener("click", () => window.location.reload());
15+
shell.replaceChildren(message, reload);
16+
});

‎apps/web/src/components/banner.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export function ComposerNotice({
5656
return (
5757
<div
5858
role="alert"
59-
className={`mx-5.5 -mb-4 flex items-center gap-2.5 rounded-t-[16px] border border-b-0 ${surface} px-3 pt-2.5 pb-6 text-[12px] leading-snug`}
59+
className={`mx-5.5 -mb-4 flex items-center gap-2.5 rounded-t-2xl border border-b-0 ${surface} px-3 pt-2.5 pb-6 text-xs leading-snug`}
6060
>
6161
<Icon className={`size-4 shrink-0 ${text}`} strokeWidth={2} aria-hidden />
6262
<div className="min-w-0 flex-1">

0 commit comments

Comments
 (0)