Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions apps/server/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,75 @@ export class FsError extends Schema.TaggedError<FsError>()("Server.FsError", {
message: Schema.String,
}) {}

/** A conversation with the agent, as listed under its project. */
export const SessionSummary = Schema.Struct({
id: Schema.String,
title: Schema.String,
/** Last activity, epoch milliseconds. */
updated: Schema.Number,
});
export type SessionSummary = typeof SessionSummary.Type;

export const Project = Schema.Struct({
id: Schema.String,
name: Schema.String,
path: Schema.String,
sessions: Schema.Array(SessionSummary),
});
export type Project = typeof Project.Type;

export const Message = Schema.Struct({
role: Schema.Literals(["user", "assistant"]),
text: Schema.String,
});
export type Message = typeof Message.Type;

export const Session = Schema.Struct({
...SessionSummary.fields,
project: Schema.String,
messages: Schema.Array(Message),
});
export type Session = typeof Session.Type;

export class SessionNotFound extends Schema.TaggedError<SessionNotFound>()("Server.SessionNotFound", {
id: Schema.String,
}) {}

export const Layout = Schema.Literals(["tile", "monocle"]);
export type Layout = typeof Layout.Type;

/**
* A workspace. `bit` is its webwm tag bit, fixed for life so reordering never
* rewrites widget masks; `pos` is its place in the switcher and its Cmd+N key.
*/
export const Workspace = Schema.Struct({
id: Schema.String,
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,
});
export type Workspace = typeof Workspace.Type;

export const WorkspaceCreate = Schema.Struct({ name: Schema.String, icon: Schema.String });
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),
});
export const WorkspaceOrder = Schema.Struct({ ids: Schema.Array(Schema.String) });
export const WorkspaceId = Schema.Struct({ id: Schema.String });

export class WorkspaceError extends Schema.TaggedError<WorkspaceError>()("Server.WorkspaceError", {
message: Schema.String,
}) {}

/** Package-scoped JSON storage: `package` namespaces keys so packages never collide. */
const KvKey = { package: Schema.String, key: Schema.String };

Expand All @@ -22,6 +91,38 @@ export const Api = RpcGroup.make(
success: Schema.Array(Entry),
error: FsError,
}),
Rpc.make("projects.list", {
success: Schema.Array(Project),
}),
Rpc.make("sessions.get", {
payload: { id: Schema.String },
success: Session,
error: SessionNotFound,
}),
Rpc.make("workspaces.list", {
success: Schema.Array(Workspace),
}),
Rpc.make("workspaces.create", {
payload: WorkspaceCreate,
success: Workspace,
error: WorkspaceError,
}),
Rpc.make("workspaces.update", {
payload: WorkspaceUpdate,
success: Schema.Void,
error: WorkspaceError,
}),
/** Puts the workspaces in this order; `ids` must name every workspace once. */
Rpc.make("workspaces.reorder", {
payload: WorkspaceOrder,
success: Schema.Void,
error: WorkspaceError,
}),
Rpc.make("workspaces.delete", {
payload: WorkspaceId,
success: Schema.Void,
error: WorkspaceError,
}),
Rpc.make("kv.get", {
payload: KvKey,
success: Schema.OptionFromNullOr(Schema.Json),
Expand Down
25 changes: 24 additions & 1 deletion apps/server/src/db.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SqliteClient, SqliteMigrator } from "@effect/sql-sqlite-node";
import { Effect, Layer, String as Str } from "effect";
import { Clock, Effect, Layer, String as Str } from "effect";
import { Migrator, SqlClient } from "effect/sql";

// Migrations ship as code so they survive bundling. Keys are
Expand All @@ -18,6 +18,29 @@ const migrations = {
)
`;
}),
"202610090002_workspaces": Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`
CREATE TABLE workspaces (
id TEXT PRIMARY KEY,
bit INTEGER NOT NULL UNIQUE,
pos INTEGER NOT NULL,
name TEXT NOT NULL,
icon TEXT NOT NULL,
layout TEXT NOT NULL,
mfact REAL NOT NULL,
created INTEGER NOT NULL
)
`;
const created = yield* Clock.currentTimeMillis;
yield* sql`INSERT INTO workspaces ${sql.insert(
[
{ id: "main", name: "Main", icon: "layout-dashboard" },
{ id: "code", name: "Code", icon: "code" },
{ id: "notes", name: "Notes", icon: "notebook-pen" },
].map((workspace, index) => ({ ...workspace, bit: index, pos: index, layout: "tile", mfact: 0.6, created })),
)}`;
}),
};

const setup = Effect.gen(function* () {
Expand Down
13 changes: 13 additions & 0 deletions apps/server/src/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Clock, Effect, FileSystem, Option, Path, Schema } from "effect";
import { SqlClient } from "effect/sql";
import { Contract, type Entry } from "./contract.ts";
import { findSession, projects } from "./mock.ts";
import { Workspaces } from "./workspaces.ts";

const JsonText = Schema.fromJsonString(Schema.Json);
const decodeJson = Schema.decodeUnknownEffect(JsonText);
Expand All @@ -19,8 +21,11 @@ export const layer = Contract.Api.toLayer(
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const sql = yield* SqlClient.SqlClient;
const workspaces = yield* Workspaces.handlers;

return Contract.Api.of({
...workspaces,

"fs.list": Effect.fn("fs.list")(function* ({ path: directory }) {
const fail = (cause: unknown) => new Contract.FsError({ path: directory, message: String(cause) });
const names = yield* fs.readDirectory(directory).pipe(Effect.mapError(fail));
Expand All @@ -43,6 +48,14 @@ export const layer = Contract.Api.toLayer(
.sort(byKindThenName);
}),

"projects.list": () => Effect.succeed(projects),

"sessions.get": Effect.fn("sessions.get")(function* ({ id }) {
const session = findSession(id);
if (session === undefined) return yield* new Contract.SessionNotFound({ id });
return session;
}),

"kv.get": Effect.fn("kv.get")(function* ({ package: pkg, key }) {
const rows = yield* sql<{ value: string }>`
SELECT value FROM kv WHERE package = ${pkg} AND key = ${key}
Expand Down
43 changes: 43 additions & 0 deletions apps/server/src/mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Message, Project, Session } from "./contract.ts";

// Stand-in data until the server talks to the harness.

const hour = 60 * 60 * 1000;
const now = Date.now();

const conversation = (topic: string): readonly Message[] => [
{ role: "user", text: `Can you help with ${topic}?` },
{ role: "assistant", text: `Sure. Let me look at the code involved in ${topic} first.` },
{ role: "user", text: "Keep the change small." },
{ role: "assistant", text: "Done: one focused change, checks pass." },
];

const sessions: readonly Session[] = [
{ id: "s1", project: "codework", title: "Desktop navigation", updated: now - 1 * hour },
{ id: "s2", project: "codework", title: "Widget persistence", updated: now - 5 * hour },
{ id: "s3", project: "codework", title: "Bus design review", updated: now - 30 * hour },
{
id: "s7",
project: "codework",
title: "Rotate the OpenRouter key sk-or-v1-20b4d7e9a1c3b8f2fce",
updated: now - 40 * hour,
},
{ id: "s4", project: "webwm", title: "Grid layout gaps", updated: now - 3 * hour },
{ id: "s5", project: "webwm", title: "Pinned slots", updated: now - 50 * hour },
{ id: "s6", project: "notes", title: "Weekly plan", updated: now - 72 * hour },
].map((session) => ({ ...session, messages: conversation(session.title.toLowerCase()) }));

const roots = [
{ id: "codework", name: "codework", path: "~/Developer/codeworksh/codework" },
{ id: "webwm", name: "webwm", path: "~/Developer/codeworksh/webwm" },
{ id: "notes", name: "notes", path: "~/Documents/notes" },
];

export const projects: readonly Project[] = roots.map((root) => ({
...root,
sessions: sessions
.filter((session) => session.project === root.id)
.map(({ id, title, updated }) => ({ id, title, updated })),
}));

export const findSession = (id: string) => sessions.find((session) => session.id === id);
101 changes: 101 additions & 0 deletions apps/server/src/workspaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Clock, Effect } from "effect";
import { SqlClient } from "effect/sql";
import {
Contract,
type Workspace,
type WorkspaceCreate,
type WorkspaceId,
type WorkspaceOrder,
type WorkspaceUpdate,
} from "./contract.ts";

// webwm tag bits a workspace may hold; also the Cmd+1–9 keys.
const MAX = 9;

const fail = (message: string) => new Contract.WorkspaceError({ message });

/** Workspace RPC handlers; the server is the only writer of the table. */
export const handlers = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

const list = sql<Workspace>`
SELECT id, bit, pos, name, icon, layout, mfact FROM workspaces ORDER BY pos
`.pipe(Effect.orDie);

// Rewrites positions as 0..n-1 in the given order.
const renumber = (ids: readonly string[]) =>
Effect.forEach(ids, (id, pos) => sql`UPDATE workspaces SET pos = ${pos} WHERE id = ${id}`, { discard: true });

return {
"workspaces.list": () => list,

"workspaces.create": Effect.fn("workspaces.create")(function* ({ name, icon }: typeof WorkspaceCreate.Type) {
return yield* sql
.withTransaction(
Effect.gen(function* () {
const current = yield* list;
if (current.length >= MAX) return yield* fail(`At most ${MAX} workspaces`);
const used = new Set(current.map((workspace) => workspace.bit));
const bit = [...Array(MAX).keys()].find((candidate) => !used.has(candidate)) ?? 0;
const workspace: Workspace = {
id: yield* Effect.sync(() => crypto.randomUUID().slice(0, 8)),
bit,
pos: current.length,
name,
icon,
layout: "tile",
mfact: 0.6,
};
const created = yield* Clock.currentTimeMillis;
yield* sql`INSERT INTO workspaces ${sql.insert({ ...workspace, created })}`;
return workspace;
}),
)
.pipe(Effect.catchTag("SqlError", Effect.die));
}),

"workspaces.update": Effect.fn("workspaces.update")(function* ({ id, ...patch }: typeof WorkspaceUpdate.Type) {
const fields = Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined));
if (Object.keys(fields).length === 0) return;
const updated = yield* sql<{
id: string;
}>`UPDATE workspaces SET ${sql.update(fields)} WHERE id = ${id} RETURNING id`.pipe(Effect.orDie);
if (updated.length === 0) return yield* fail(`No workspace ${id}`);
}),

"workspaces.reorder": Effect.fn("workspaces.reorder")(function* ({ ids }: typeof WorkspaceOrder.Type) {
yield* sql
.withTransaction(
Effect.gen(function* () {
const current = yield* list;
const known = new Set(current.map((workspace) => workspace.id));
if (
ids.length !== known.size ||
new Set(ids).size !== ids.length ||
!ids.every((id) => known.has(id))
)
return yield* fail("The order must name every workspace once");
yield* renumber(ids);
}),
)
.pipe(Effect.catchTag("SqlError", Effect.die));
}),

// Widgets keep their masks here; the client clears the deleted bit from its instances.
"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}`);
if (current.length === 1) return yield* fail("The last workspace can't be deleted");
yield* sql`DELETE FROM workspaces WHERE id = ${id}`;
yield* renumber(current.filter((workspace) => workspace.id !== id).map((workspace) => workspace.id));
}),
)
.pipe(Effect.catchTag("SqlError", Effect.die));
}),
};
});

export * as Workspaces from "./workspaces.ts";
21 changes: 21 additions & 0 deletions apps/web/components.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/style.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
6 changes: 6 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,18 @@
"dependencies": {
"@codeworksh/server": "workspace:*",
"@effect/atom-react": "4.0.1",
"@fontsource-variable/inter": "^5.3.0",
"@pierre/trees": "1.0.0-beta.6",
"@tanstack/react-router": "^1.161.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"effect": "4.0.1",
"lucide-react": "^1.52.0",
"radix-ui": "^1.7.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"scheduler": "^0.27.0",
"tailwind-merge": "^3.7.0",
"webwm": "https://github.com/codeworksh/webwm/releases/download/v0.1.0/webwm-0.1.0.tgz"
},
"devDependencies": {
Expand All @@ -29,6 +34,7 @@
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.0.0",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plus": "catalog:"
Expand Down
Loading