From d613c77a186f2595ef911e9a58505a32a851a9fa Mon Sep 17 00:00:00 2001 From: kate bonner Date: Wed, 22 Jul 2026 18:57:48 -0400 Subject: [PATCH 01/18] feat(app): Defaults capsule copy + drop Vaults tab from the global popover (amicode#203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AC8: the home-chrome global popover (the 'Connections' entry) no longer shows a Vaults tab — vaults live in the native Armonia sidebar now. Parameterized the shared status tabs with includeVaults (default true), so the session-view popover keeps its vaults tab; the global mount passes false. GLOBAL_STATUS_TABS -> ['connections']; model test updated. AC9: defaults capsule copy 'Session defaults' -> 'Defaults — model & solver, apply everywhere' (copy only; no stored state touched). Part of harmoniqs/amicode#203. Co-Authored-By: Claude Fable 5 --- .../components/amicode-defaults-capsule.tsx | 4 +- .../src/components/status-popover-body.tsx | 51 +++++++++++-------- .../components/status-popover-model.test.ts | 13 ++--- .../src/components/status-popover-model.ts | 4 +- 4 files changed, 41 insertions(+), 31 deletions(-) diff --git a/packages/app/src/components/amicode-defaults-capsule.tsx b/packages/app/src/components/amicode-defaults-capsule.tsx index eae964f9eb..eb5838cfed 100644 --- a/packages/app/src/components/amicode-defaults-capsule.tsx +++ b/packages/app/src/components/amicode-defaults-capsule.tsx @@ -189,7 +189,7 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl + + +
+
+ + +
- -
+ +
@@ -830,9 +837,9 @@ export function StatusPopoverGlobalBody(props: { variant="alt" > - + - +
) diff --git a/packages/app/src/components/status-popover-model.test.ts b/packages/app/src/components/status-popover-model.test.ts index 00bafe04ab..4103ee1275 100644 --- a/packages/app/src/components/status-popover-model.test.ts +++ b/packages/app/src/components/status-popover-model.test.ts @@ -24,12 +24,13 @@ describe("statusTriggerVisibility", () => { }) describe("global status surface (home chrome entry)", () => { - // amicode#174 AC1: the home-chrome entry opens the same Vaults + Connections - // surface the session popover hosts, with Connections pre-selected. The - // per-directory tabs (mcp/lsp/plugins) are NOT part of the global surface — - // home has no directory-scoped sync context. - test("global surface hosts exactly vaults + connections", () => { - expect([...GLOBAL_STATUS_TABS]).toEqual(["vaults", "connections"]) + // amicode#202: vaults moved to the native Armonia sidebar panel, so the + // home-chrome entry hosts Connections only (was vaults + connections in + // #174). The per-directory tabs (mcp/lsp/plugins) are NOT part of the global + // surface — home has no directory-scoped sync context. + test("global surface hosts connections only (vaults moved to the sidebar)", () => { + expect([...GLOBAL_STATUS_TABS]).toEqual(["connections"]) + expect([...GLOBAL_STATUS_TABS]).not.toContain("vaults") }) test("connections is the pre-selected tab", () => { diff --git a/packages/app/src/components/status-popover-model.ts b/packages/app/src/components/status-popover-model.ts index c274c7adf0..2fe6a4c531 100644 --- a/packages/app/src/components/status-popover-model.ts +++ b/packages/app/src/components/status-popover-model.ts @@ -31,7 +31,9 @@ export function statusTriggerVisibility(input: { desktopV2: boolean; showStatus: * per-directory (they read the directory-scoped sync context, which the home * route does not mount) and are meaningless before a session exists. */ -export const GLOBAL_STATUS_TABS = ["vaults", "connections"] as const +// amicode#202: vaults moved to the native Armonia sidebar panel, so the +// global home-chrome surface hosts Connections only. +export const GLOBAL_STATUS_TABS = ["connections"] as const export type GlobalStatusTab = (typeof GLOBAL_STATUS_TABS)[number] /** The home entry is labeled "Connections", so that tab opens pre-selected. */ From 949d6f5c9f1fde3ab0be8964c4a57c9274067c2f Mon Sep 17 00:00:00 2001 From: kate bonner Date: Wed, 22 Jul 2026 19:00:05 -0400 Subject: [PATCH 02/18] =?UTF-8?q?feat(app):=20project-first=20pure=20logic?= =?UTF-8?q?=20=E2=80=94=20slug,=20create-error=20classify,=20orphan-aware?= =?UTF-8?q?=20session=20grouping=20(amicode#203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the Projects menu + New-project flow (home-projects.ts): slug, runtime create-error classification (collision/unwritable/other), best-effort git-init notice, and groupSessionsByProject — which nests sessions under their project and groups the rest as orphans by directory, replacing the old drop-on-no-project behavior (AC7). 10 unit tests. Part of harmoniqs/amicode#203. --- packages/app/src/pages/home-projects.test.ts | 104 +++++++++++++++++++ packages/app/src/pages/home-projects.ts | 93 +++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 packages/app/src/pages/home-projects.test.ts create mode 100644 packages/app/src/pages/home-projects.ts diff --git a/packages/app/src/pages/home-projects.test.ts b/packages/app/src/pages/home-projects.test.ts new file mode 100644 index 0000000000..9b93332cdb --- /dev/null +++ b/packages/app/src/pages/home-projects.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test" +import { + classifyCreateError, + gitInitNeedsNotice, + groupSessionsByProject, + projectNameToSlug, + resolveCreationTarget, +} from "./home-projects" + +describe("projectNameToSlug", () => { + test("lowercases, hyphenates whitespace/underscores, strips junk, trims", () => { + expect(projectNameToSlug("My CZ Gate")).toBe("my-cz-gate") + expect(projectNameToSlug(" Rydberg__MIS ")).toBe("rydberg-mis") + expect(projectNameToSlug("Bell (state) #2!")).toBe("bell-state-2") + expect(projectNameToSlug("a---b")).toBe("a-b") + }) + test("names that slugify to nothing return empty", () => { + expect(projectNameToSlug("")).toBe("") + expect(projectNameToSlug(" ")).toBe("") + expect(projectNameToSlug("!!!")).toBe("") + }) +}) + +describe("resolveCreationTarget", () => { + test("joins parent + slug, handling a trailing slash", () => { + expect(resolveCreationTarget({ name: "My Gate", parentDir: "/home/kate/proj" })).toEqual({ + ok: true, + slug: "my-gate", + path: "/home/kate/proj/my-gate", + }) + expect(resolveCreationTarget({ name: "My Gate", parentDir: "/home/kate/proj/" }).path).toBe( + "/home/kate/proj/my-gate", + ) + }) + test("rejects a name that slugifies to nothing", () => { + expect(resolveCreationTarget({ name: " ", parentDir: "/x" })).toEqual({ ok: false, reason: "empty-name" }) + }) +}) + +describe("classifyCreateError", () => { + test("maps errno codes to inline error kinds", () => { + expect(classifyCreateError({ code: "EEXIST" })).toBe("collision") + expect(classifyCreateError({ code: "EACCES" })).toBe("unwritable") + expect(classifyCreateError({ code: "EROFS" })).toBe("unwritable") + expect(classifyCreateError({ code: "ENOENT" })).toBe("unwritable") + expect(classifyCreateError({ code: "EIO" })).toBe("other") + expect(classifyCreateError(undefined)).toBe("other") + }) + test("falls back to message text when no code", () => { + expect(classifyCreateError({ message: "file already exists" })).toBe("collision") + expect(classifyCreateError({ message: "permission denied" })).toBe("unwritable") + expect(classifyCreateError({ message: "kaboom" })).toBe("other") + }) +}) + +describe("gitInitNeedsNotice", () => { + test("notice iff git init did not succeed", () => { + expect(gitInitNeedsNotice({ ok: true })).toBe(false) + expect(gitInitNeedsNotice({ ok: false })).toBe(true) + }) +}) + +describe("groupSessionsByProject", () => { + const P = (worktree: string, name = worktree) => ({ worktree, name }) + const S = (id: string, directory: string) => ({ id, directory }) + const opts = (projects: ReturnType[], sessions: ReturnType[]) => ({ + projects, + sessions, + projectOf: (s: ReturnType) => projects.find((p) => p.worktree === s.directory), + directoryOf: (s: ReturnType) => s.directory, + }) + + test("nests sessions under their project, preserving orders; empty projects still appear", () => { + const a = P("/a") + const b = P("/b") + const { projectGroups, orphans } = groupSessionsByProject( + opts([a, b], [S("s1", "/a"), S("s2", "/b"), S("s3", "/a")]), + ) + expect(projectGroups.map((g) => g.project.worktree)).toEqual(["/a", "/b"]) + expect(projectGroups[0].sessions.map((s) => s.id)).toEqual(["s1", "s3"]) + expect(projectGroups[1].sessions.map((s) => s.id)).toEqual(["s2"]) + expect(orphans).toEqual([]) + }) + + test("sessions with no registered project become orphans grouped by directory (never dropped)", () => { + const a = P("/a") + const { projectGroups, orphans } = groupSessionsByProject( + opts([a], [S("s1", "/a"), S("ghost1", "/tmp/x"), S("ghost2", "/tmp/x"), S("ghost3", "/tmp/y")]), + ) + expect(projectGroups[0].sessions.map((s) => s.id)).toEqual(["s1"]) + expect(orphans.map((o) => o.directory)).toEqual(["/tmp/x", "/tmp/y"]) + expect(orphans[0].sessions.map((s) => s.id)).toEqual(["ghost1", "ghost2"]) + // every session is reachable: nothing silently dropped + const reachable = projectGroups.flatMap((g) => g.sessions).length + orphans.flatMap((o) => o.sessions).length + expect(reachable).toBe(4) + }) + + test("a project with no sessions appears with an empty list", () => { + const { projectGroups, orphans } = groupSessionsByProject(opts([P("/lonely")], [])) + expect(projectGroups).toHaveLength(1) + expect(projectGroups[0].sessions).toEqual([]) + expect(orphans).toEqual([]) + }) +}) diff --git a/packages/app/src/pages/home-projects.ts b/packages/app/src/pages/home-projects.ts new file mode 100644 index 0000000000..b885c6edf5 --- /dev/null +++ b/packages/app/src/pages/home-projects.ts @@ -0,0 +1,93 @@ +// amicode#203: project-first dashboard logic — pure helpers for the Projects +// menu and the New-project creation flow. Kept vscode/solid-free so it unit- +// tests headless (the home.tsx consumer wires these to the SDK + dialogs). + +/** Slugify a project name into a folder basename: lowercase, whitespace and + * underscores to hyphens, drop anything outside [a-z0-9-], collapse repeats, + * trim leading/trailing hyphens. */ +export function projectNameToSlug(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[\s_]+/g, "-") + .replace(/[^a-z0-9-]/g, "") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") +} + +export type CreationTarget = + | { ok: true; slug: string; path: string } + | { ok: false; reason: "empty-name" } + +/** Resolve the target directory for a new project from its name + parent. + * Collision/permission are RUNTIME outcomes (see classifyCreateError) — this + * only rejects a name that slugifies to nothing. */ +export function resolveCreationTarget(input: { name: string; parentDir: string }): CreationTarget { + const slug = projectNameToSlug(input.name) + if (!slug) return { ok: false, reason: "empty-name" } + const sep = input.parentDir.endsWith("/") ? "" : "/" + return { ok: true, slug, path: `${input.parentDir}${sep}${slug}` } +} + +export type CreateErrorKind = "collision" | "unwritable" | "other" + +/** Map a filesystem error (by errno code, else message) to the inline error the + * dialog shows. EEXIST is a name collision; permission/read-only/missing-parent + * is an unwritable location; anything else is a generic failure. */ +export function classifyCreateError(err: { code?: string; message?: string } | undefined): CreateErrorKind { + const code = (err?.code ?? "").toUpperCase() + if (code === "EEXIST") return "collision" + if (code === "EACCES" || code === "EPERM" || code === "EROFS" || code === "ENOENT" || code === "ENOTDIR") + return "unwritable" + const msg = (err?.message ?? "").toLowerCase() + if (msg.includes("exists")) return "collision" + if (msg.includes("permission") || msg.includes("read-only") || msg.includes("not a directory")) return "unwritable" + return "other" +} + +/** Best-effort git-init: the project is created regardless; a non-ok result + * only means change-tracking is unavailable and the caller shows a notice. */ +export function gitInitNeedsNotice(result: { ok: boolean }): boolean { + return !result.ok +} + +export type ProjectGroup = { project: P; sessions: S[] } +export type OrphanGroup = { directory: string; sessions: S[] } + +/** Nest sessions under their project; sessions whose project is unknown become + * orphans grouped by directory — NEVER dropped (amicode#203 AC7, replacing the + * old drop-on-no-project behavior). Projects keep their input order and appear + * even with zero sessions; sessions keep their input order within a group; + * orphan groups appear in first-seen order. */ +export function groupSessionsByProject

(input: { + projects: P[] + sessions: S[] + projectOf: (s: S) => P | undefined + directoryOf: (s: S) => string +}): { projectGroups: ProjectGroup[]; orphans: OrphanGroup[] } { + const byWorktree = new Map>() + const projectGroups = input.projects.map((project) => { + const g: ProjectGroup = { project, sessions: [] } + byWorktree.set(project.worktree, g) + return g + }) + const orphanMap = new Map>() + const orphans: OrphanGroup[] = [] + for (const s of input.sessions) { + const p = input.projectOf(s) + const group = p ? byWorktree.get(p.worktree) : undefined + if (group) { + group.sessions.push(s) + continue + } + const dir = input.directoryOf(s) + let og = orphanMap.get(dir) + if (!og) { + og = { directory: dir, sessions: [] } + orphanMap.set(dir, og) + orphans.push(og) + } + og.sessions.push(s) + } + return { projectGroups, orphans } +} From 6c611f608537b2e2075afca31a3f48a559aa0d97 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Wed, 22 Jul 2026 19:06:15 -0400 Subject: [PATCH 03/18] =?UTF-8?q?feat(server):=20POST=20/amicode/project?= =?UTF-8?q?=20=E2=80=94=20mkdir=20+=20best-effort=20git=20init=20(amicode#?= =?UTF-8?q?203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New-project creation as an amicode server route (same fs-mutation idiom as vaults.ts): {name, parentDir} -> slugify -> validate absolute parent -> mkdir -> best-effort git init. Never rejects; returns ok:false {collision|unwritable| empty-name|bad-parent|other} for inline dialog errors, or ok:true {path, slug, gitInitialized}. Slug is [a-z0-9-] only (no path traversal). git init is best-effort — absent/failed git still creates the project (AC4). 10 tests incl. a real-temp-dir end-to-end. Registered on authOnlyRouterLayer. Part of harmoniqs/amicode#203. --- .../opencode/src/server/amicode/project.ts | 105 ++++++++++++++++++ .../server/routes/instance/httpapi/server.ts | 10 ++ .../test/server/amicode-project.test.ts | 88 +++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 packages/opencode/src/server/amicode/project.ts create mode 100644 packages/opencode/test/server/amicode-project.test.ts diff --git a/packages/opencode/src/server/amicode/project.ts b/packages/opencode/src/server/amicode/project.ts new file mode 100644 index 0000000000..8e2a120281 --- /dev/null +++ b/packages/opencode/src/server/amicode/project.ts @@ -0,0 +1,105 @@ +// amicode#203: New-project creation — mkdir + best-effort git init behind an +// amicode server route (same fs-mutation idiom as vaults.ts / library.ts). The +// app calls POST /amicode/project with {name, parentDir}; the response is a +// JSON string, never a rejection — failures come back as ok:false bodies so the +// dialog can render an inline, recoverable error. +import { spawnSync } from "node:child_process" +import { existsSync, mkdirSync, rmSync } from "node:fs" +import path from "node:path" + +/** Slugify a project name into a folder basename. Mirrors the app-side + * projectNameToSlug (home-projects.ts) — kept in sync by tests on both sides. */ +export function slugify(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[\s_]+/g, "-") + .replace(/[^a-z0-9-]/g, "") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") +} + +export type CreateProjectResult = + | { ok: true; path: string; slug: string; gitInitialized: boolean } + | { ok: false; error: "empty-name" | "bad-parent" | "collision" | "unwritable" | "other"; message: string } + +/** Parse + validate the request body into a concrete target, without touching + * the filesystem. Exported for unit tests. */ +export function planCreate(rawBody: string): { target: string; slug: string } | Extract { + let parsed: unknown + try { + parsed = JSON.parse(rawBody) + } catch { + return { ok: false, error: "other", message: "malformed request body" } + } + const body = (typeof parsed === "object" && parsed !== null ? parsed : {}) as Record + const name = typeof body.name === "string" ? body.name : "" + const parentDir = typeof body.parentDir === "string" ? body.parentDir : "" + const slug = slugify(name) + if (!slug) return { ok: false, error: "empty-name", message: "Enter a project name." } + // parentDir must be an absolute path — never resolve a client-supplied + // relative path against the server's cwd, and the sanitized slug can't + // traverse (it is [a-z0-9-] only). + if (!parentDir || !path.isAbsolute(parentDir)) + return { ok: false, error: "bad-parent", message: "Choose a location for the project." } + return { target: path.join(parentDir, slug), slug } +} + +/** Map a Node fs error to the inline error kind the dialog shows. */ +export function classifyFsError(err: unknown): Extract["error"] { + const code = ((err as { code?: string })?.code ?? "").toUpperCase() + if (code === "EEXIST") return "collision" + if (code === "EACCES" || code === "EPERM" || code === "EROFS" || code === "ENOENT" || code === "ENOTDIR") + return "unwritable" + return "other" +} + +/** Create the project directory and best-effort git-init it. The directory + + * its registration is the atomic unit; git init layers on top and never + * blocks creation (amicode#203 AC4). Injectable deps for tests. */ +export function createProjectAt( + target: string, + slug: string, + deps: { + exists?: (p: string) => boolean + mkdir?: (p: string) => void + gitInit?: (cwd: string) => boolean + } = {}, +): CreateProjectResult { + const exists = deps.exists ?? existsSync + const mkdir = deps.mkdir ?? ((p: string) => void mkdirSync(p, { recursive: true })) + const gitInit = + deps.gitInit ?? + ((cwd: string) => { + try { + return spawnSync("git", ["init"], { cwd, stdio: "ignore" }).status === 0 + } catch { + return false // git absent from PATH → best-effort, project still created + } + }) + + if (exists(target)) return { ok: false, error: "collision", message: "A project with this name already exists here." } + try { + mkdir(target) + } catch (err) { + const kind = classifyFsError(err) + return { ok: false, error: kind, message: kind === "unwritable" ? "That location can't be written to." : "Could not create the project." } + } + let gitInitialized = false + try { + gitInitialized = gitInit(target) + } catch { + gitInitialized = false + } + return { ok: true, path: target, slug, gitInitialized } +} + +/** POST /amicode/project handler body → JSON string (never rejects). */ +export function createProject(rawBody: string): string { + const plan = planCreate(rawBody) + if ("ok" in plan) return JSON.stringify(plan) + return JSON.stringify(createProjectAt(plan.target, plan.slug)) +} + +// re-export for a create-then-cleanup path if a caller ever needs to roll back +export const _internal = { rmSync } diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 8ccc061cd2..9b361d4e91 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -66,6 +66,7 @@ import * as AmicodeWidgetFrame from "@/server/amicode/widget-frame-html" import * as AmicodeLibrary from "@/server/amicode/library" import * as AmicodeProfile from "@/server/amicode/profile" import * as AmicodeConnections from "@/server/amicode/connections" +import * as AmicodeProject from "@/server/amicode/project" import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" import { Api } from "@opencode-ai/server/api" @@ -202,6 +203,15 @@ const amicodeVaultsRoute = HttpRouter.use((router) => return HttpServerResponse.text(out, { contentType: "application/json" }) }), ) + // amicode#203: New-project creation — mkdir + best-effort git init. JSON + // body {name, parentDir}; never rejects (failures come back as ok:false). + yield* router.add("POST", "/amicode/project", (request) => + Effect.gen(function* () { + const body = yield* Effect.orDie(request.text) + const out = AmicodeProject.createProject(body) + return HttpServerResponse.text(out, { contentType: "application/json" }) + }), + ) }), ).pipe(Layer.provide(authOnlyRouterLayer)) diff --git a/packages/opencode/test/server/amicode-project.test.ts b/packages/opencode/test/server/amicode-project.test.ts new file mode 100644 index 0000000000..736ed995dd --- /dev/null +++ b/packages/opencode/test/server/amicode-project.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test" +import { mkdtempSync, existsSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { slugify, planCreate, classifyFsError, createProjectAt, createProject } from "@/server/amicode/project" + +describe("slugify (server, mirrors app projectNameToSlug)", () => { + test("normalizes to a safe folder basename", () => { + expect(slugify("My CZ Gate")).toBe("my-cz-gate") + expect(slugify("Bell (state) #2!")).toBe("bell-state-2") + expect(slugify(" ")).toBe("") + }) + test("cannot produce a path traversal", () => { + expect(slugify("../../etc")).toBe("etc") + expect(slugify("a/../b")).toBe("ab") + }) +}) + +describe("planCreate", () => { + test("rejects an empty name and a non-absolute/empty parent", () => { + expect(planCreate(JSON.stringify({ name: " ", parentDir: "/abs" }))).toMatchObject({ ok: false, error: "empty-name" }) + expect(planCreate(JSON.stringify({ name: "X", parentDir: "rel/ative" }))).toMatchObject({ ok: false, error: "bad-parent" }) + expect(planCreate(JSON.stringify({ name: "X" }))).toMatchObject({ ok: false, error: "bad-parent" }) + expect(planCreate("not json")).toMatchObject({ ok: false, error: "other" }) + }) + test("joins an absolute parent with the slug", () => { + expect(planCreate(JSON.stringify({ name: "My Gate", parentDir: "/home/kate/p" }))).toEqual({ + target: "/home/kate/p/my-gate", + slug: "my-gate", + }) + }) +}) + +describe("classifyFsError", () => { + test("maps codes to inline kinds", () => { + expect(classifyFsError({ code: "EEXIST" })).toBe("collision") + expect(classifyFsError({ code: "EACCES" })).toBe("unwritable") + expect(classifyFsError({ code: "EIO" })).toBe("other") + expect(classifyFsError(undefined)).toBe("other") + }) +}) + +describe("createProjectAt (injected deps — no real fs/git)", () => { + test("collision when the target already exists", () => { + const r = createProjectAt("/p/x", "x", { exists: () => true }) + expect(r).toMatchObject({ ok: false, error: "collision" }) + }) + test("created + git initialized on the happy path", () => { + let made = "" + const r = createProjectAt("/p/x", "x", { exists: () => false, mkdir: (p) => void (made = p), gitInit: () => true }) + expect(made).toBe("/p/x") + expect(r).toEqual({ ok: true, path: "/p/x", slug: "x", gitInitialized: true }) + }) + test("created WITHOUT git when git init fails or git is absent (best-effort, AC4)", () => { + const r = createProjectAt("/p/x", "x", { + exists: () => false, + mkdir: () => {}, + gitInit: () => { + throw new Error("git: command not found") + }, + }) + expect(r).toEqual({ ok: true, path: "/p/x", slug: "x", gitInitialized: false }) + }) + test("unwritable when mkdir throws EACCES — no project created", () => { + const r = createProjectAt("/p/x", "x", { + exists: () => false, + mkdir: () => { + const e = new Error("denied") as Error & { code?: string } + e.code = "EACCES" + throw e + }, + }) + expect(r).toMatchObject({ ok: false, error: "unwritable" }) + }) +}) + +describe("createProject (end-to-end against a real temp dir)", () => { + test("creates a real directory and best-effort git-inits it", () => { + const parent = mkdtempSync(path.join(tmpdir(), "amicode-proj-")) + const out = JSON.parse(createProject(JSON.stringify({ name: "Rydberg MIS", parentDir: parent }))) + expect(out.ok).toBe(true) + expect(out.slug).toBe("rydberg-mis") + expect(existsSync(path.join(parent, "rydberg-mis"))).toBe(true) + // second create at the same name collides + const dup = JSON.parse(createProject(JSON.stringify({ name: "Rydberg MIS", parentDir: parent }))) + expect(dup).toMatchObject({ ok: false, error: "collision" }) + }) +}) From 9181193e0eba4ff005276301224cdc857138dfe3 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Wed, 22 Jul 2026 19:15:45 -0400 Subject: [PATCH 04/18] feat(app): New-project creation dialog wired to the dashboard (amicode#203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prominent add-project affordance now opens a New-project dialog (name -> slug preview) that creates a real directory via POST /amicode/project, then registers it and opens a first session. Inline recoverable errors for collision/unwritable; 'Open existing folder instead…' falls back to the directory picker; a git-absent create still opens the project with a non-blocking toast (AC4). Uses the shared amicodePost helper. Satisfies AC1-5 of harmoniqs/amicode#203 (creation flow). Co-Authored-By: Claude Fable 5 --- .../app/src/components/dialog-new-project.tsx | 102 ++++++++++++++++++ packages/app/src/pages/home-projects.test.ts | 5 +- packages/app/src/pages/home.tsx | 33 +++++- .../opencode/src/server/amicode/project.ts | 18 +++- .../test/server/amicode-project.test.ts | 11 +- 5 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 packages/app/src/components/dialog-new-project.tsx diff --git a/packages/app/src/components/dialog-new-project.tsx b/packages/app/src/components/dialog-new-project.tsx new file mode 100644 index 0000000000..e75364a8a0 --- /dev/null +++ b/packages/app/src/components/dialog-new-project.tsx @@ -0,0 +1,102 @@ +// amicode#203: New-project creation dialog. Name → slug preview; the server +// owns the location default (~/AmicodeProjects) and the mkdir + best-effort +// git init (POST /amicode/project). Inline, recoverable errors for collision / +// unwritable; a non-blocking git notice is surfaced by the caller (onCreated). +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { TextField } from "@opencode-ai/ui/text-field" +import { useMutation } from "@tanstack/solid-query" +import { createMemo, createSignal, Show } from "solid-js" +import { ServerConnection } from "@/context/server" +import { amicodePost } from "@/utils/amicode-fetch" +import { projectNameToSlug } from "@/pages/home-projects" + +type CreateResult = + | { ok: true; path: string; slug: string; gitInitialized: boolean } + | { ok: false; error: string; message: string } + +export function DialogNewProject(props: { + server: ServerConnection.Any + onCreated: (result: { path: string; gitInitialized: boolean }) => void + onOpenExisting: () => void +}) { + const dialog = useDialog() + const [name, setName] = createSignal("") + const [error, setError] = createSignal() + const slug = createMemo(() => projectNameToSlug(name())) + + const createMutation = useMutation(() => ({ + mutationFn: async (): Promise => { + setError(undefined) + const result = (await amicodePost(props.server, "/amicode/project", { name: name() }).catch( + () => undefined, + )) as CreateResult | undefined + if (!result) { + setError("Couldn't reach the server — try again.") + return + } + if (!result.ok) { + setError(result.message) + return + } + dialog.close() + props.onCreated({ path: result.path, gitInitialized: result.gitInitialized }) + }, + })) + + function submit(e: SubmitEvent) { + e.preventDefault() + if (createMutation.isPending || !slug()) return + createMutation.mutate() + } + + return ( +

+
+
+ + Creates a new folder in ~/AmicodeProjects.} + > + + Creates {slug()} in ~/AmicodeProjects, with git for change + tracking. + + +
+ + +
+ ) +} diff --git a/packages/app/src/pages/home-projects.test.ts b/packages/app/src/pages/home-projects.test.ts index 9b93332cdb..4db2394635 100644 --- a/packages/app/src/pages/home-projects.test.ts +++ b/packages/app/src/pages/home-projects.test.ts @@ -28,9 +28,8 @@ describe("resolveCreationTarget", () => { slug: "my-gate", path: "/home/kate/proj/my-gate", }) - expect(resolveCreationTarget({ name: "My Gate", parentDir: "/home/kate/proj/" }).path).toBe( - "/home/kate/proj/my-gate", - ) + const trailing = resolveCreationTarget({ name: "My Gate", parentDir: "/home/kate/proj/" }) + expect(trailing.ok && trailing.path).toBe("/home/kate/proj/my-gate") }) test("rejects a name that slugifies to nothing", () => { expect(resolveCreationTarget({ name: " ", parentDir: "/x" })).toEqual({ ok: false, reason: "empty-name" }) diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 61fafbe184..52e87c4b6c 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -52,6 +52,7 @@ import { } from "@/pages/layout/helpers" import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" import { sessionTitle } from "@/utils/session-title" +import { showToast } from "@/utils/toast" import { pathKey } from "@/utils/path-key" import { useGlobal } from "@/context/global" import { useCommand } from "@/context/command" @@ -790,6 +791,30 @@ function HomeDesign() { }) } + // amicode#203: true project creation — the New-project dialog names + creates + // the directory (server does mkdir + best-effort git init), then we register + // it and open a first session. "Open existing folder instead…" falls back to + // chooseProject. git-absent is non-blocking (AC4): a toast, project still opens. + function newProject(conn: ServerConnection.Any) { + void import("@/components/dialog-new-project").then((x) => { + dialog.show(() => ( + { + openProjectNewSession(conn, path) + if (!gitInitialized) + showToast({ + variant: "default", + title: "Project created", + description: "Change tracking is off — git wasn't found on your PATH.", + }) + }} + onOpenExisting={() => chooseProject(conn)} + /> + )) + }) + } + function openSettings() { void import("@/components/settings-v2").then((x) => { dialog.show(() => ) @@ -919,6 +944,7 @@ function HomeDesign() { selectProject={selectProject} openNewSession={openProjectNewSession} chooseProject={(conn) => void chooseProject(conn)} + newProject={(conn) => void newProject(conn)} editProject={editProject} closeProject={(conn, directory) => { const next = closeHomeProject( @@ -1175,6 +1201,7 @@ function HomeProjectColumn(props: { selectProject: (server: ServerConnection.Any, directory: string) => void openNewSession: (server: ServerConnection.Any, directory: string) => void chooseProject: (server: ServerConnection.Any) => void + newProject: (server: ServerConnection.Any) => void editProject: (server: ServerConnection.Any, project: LocalProject) => void closeProject: (server: ServerConnection.Any, directory: string) => void clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void @@ -1246,7 +1273,7 @@ function HomeProjectColumn(props: { size="large" class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted" icon={} - onClick={() => props.chooseProject(global.servers.list()[0]!)} + onClick={() => props.newProject(global.servers.list()[0]!)} aria-label={props.language.t("home.project.add")} /> @@ -1270,6 +1297,7 @@ function HomeProjectColumn(props: { controller={controller} focusServer={props.focusServer} chooseProject={props.chooseProject} + newProject={props.newProject} openEdit={(server) => dialog.show(() => )} language={props.language} /> @@ -1306,6 +1334,7 @@ function HomeServerRow(props: { controller: ReturnType focusServer: (server: ServerConnection.Any) => void chooseProject: (server: ServerConnection.Any) => void + newProject: (server: ServerConnection.Any) => void openEdit: (server: ServerConnection.Http) => void language: ReturnType }) { @@ -1350,7 +1379,7 @@ function HomeServerRow(props: { size="small" icon={} aria-label={props.language.t("home.project.add")} - onClick={() => props.chooseProject(props.server)} + onClick={() => props.newProject(props.server)} /> diff --git a/packages/opencode/src/server/amicode/project.ts b/packages/opencode/src/server/amicode/project.ts index 8e2a120281..aa67e2bd04 100644 --- a/packages/opencode/src/server/amicode/project.ts +++ b/packages/opencode/src/server/amicode/project.ts @@ -5,8 +5,15 @@ // dialog can render an inline, recoverable error. import { spawnSync } from "node:child_process" import { existsSync, mkdirSync, rmSync } from "node:fs" +import { homedir } from "node:os" import path from "node:path" +/** The default parent when the client sends none — the webview doesn't know the + * user's home dir, so the server owns the default (created on first use). */ +export function defaultParentDir(home: string = homedir()): string { + return path.join(home, "AmicodeProjects") +} + /** Slugify a project name into a folder basename. Mirrors the app-side * projectNameToSlug (home-projects.ts) — kept in sync by tests on both sides. */ export function slugify(name: string): string { @@ -37,12 +44,13 @@ export function planCreate(rawBody: string): { target: string; slug: string } | const parentDir = typeof body.parentDir === "string" ? body.parentDir : "" const slug = slugify(name) if (!slug) return { ok: false, error: "empty-name", message: "Enter a project name." } - // parentDir must be an absolute path — never resolve a client-supplied - // relative path against the server's cwd, and the sanitized slug can't - // traverse (it is [a-z0-9-] only). - if (!parentDir || !path.isAbsolute(parentDir)) + // No parent → the server default (~/AmicodeProjects); a provided parent must + // be absolute (never resolve a client-supplied relative path against the + // server cwd). The sanitized slug can't traverse (it is [a-z0-9-] only). + const parent = parentDir ? parentDir : defaultParentDir() + if (!path.isAbsolute(parent)) return { ok: false, error: "bad-parent", message: "Choose a location for the project." } - return { target: path.join(parentDir, slug), slug } + return { target: path.join(parent, slug), slug } } /** Map a Node fs error to the inline error kind the dialog shows. */ diff --git a/packages/opencode/test/server/amicode-project.test.ts b/packages/opencode/test/server/amicode-project.test.ts index 736ed995dd..a25b55ab8a 100644 --- a/packages/opencode/test/server/amicode-project.test.ts +++ b/packages/opencode/test/server/amicode-project.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { mkdtempSync, existsSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" -import { slugify, planCreate, classifyFsError, createProjectAt, createProject } from "@/server/amicode/project" +import { slugify, planCreate, classifyFsError, createProjectAt, createProject, defaultParentDir } from "@/server/amicode/project" describe("slugify (server, mirrors app projectNameToSlug)", () => { test("normalizes to a safe folder basename", () => { @@ -17,12 +17,17 @@ describe("slugify (server, mirrors app projectNameToSlug)", () => { }) describe("planCreate", () => { - test("rejects an empty name and a non-absolute/empty parent", () => { + test("rejects an empty name and a provided-but-relative parent", () => { expect(planCreate(JSON.stringify({ name: " ", parentDir: "/abs" }))).toMatchObject({ ok: false, error: "empty-name" }) expect(planCreate(JSON.stringify({ name: "X", parentDir: "rel/ative" }))).toMatchObject({ ok: false, error: "bad-parent" }) - expect(planCreate(JSON.stringify({ name: "X" }))).toMatchObject({ ok: false, error: "bad-parent" }) expect(planCreate("not json")).toMatchObject({ ok: false, error: "other" }) }) + test("no parent → the server default (~/AmicodeProjects)", () => { + expect(planCreate(JSON.stringify({ name: "X" }))).toEqual({ + target: path.join(defaultParentDir(), "x"), + slug: "x", + }) + }) test("joins an absolute parent with the slug", () => { expect(planCreate(JSON.stringify({ name: "My Gate", parentDir: "/home/kate/p" }))).toEqual({ target: "/home/kate/p/my-gate", From 6a8c89cddf1e23ef95c9f06c5e937d7c3fb2b638 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Wed, 22 Jul 2026 19:35:03 -0400 Subject: [PATCH 05/18] feat(app): Projects entry rename + orphan sessions surfaced (amicode#203 AC6 label, AC7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The chrome 'Sessions' entry is now 'Projects'; the pill shows the project count (or the unseen-session badge when there are unread sessions). - buildHomeSessionRecords no longer drops sessions whose directory matches no registered project — they surface as orphans labeled by directory basename, so ghost sessions stay reachable (was a silent 'return []'). AC7 (reachability) done; AC6 label done. Per-project session *nesting* (AC6's nesting clause) remains — the design-sensitive flyout rework. Part of harmoniqs/amicode#203. --- packages/app/src/pages/home.tsx | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 52e87c4b6c..0fdf6bbe77 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -136,7 +136,18 @@ function buildHomeSessionRecords(input: { .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) .flatMap((session) => { const project = projectForSession(session, input.projects(), input.projectByID()) - if (!project) return [] + // amicode#203 AC7: a session whose directory matches no registered + // project is an ORPHAN — surfaced under its directory, never dropped + // (the old `return []` silently hid ghost sessions). + if (!project) { + const dir = session.directory + const base = dir.split("/").filter(Boolean).pop() ?? dir + return { + session, + project: { worktree: dir, expanded: false } as LocalProject, + projectName: base, + } + } return { session, project, @@ -883,9 +894,10 @@ function HomeDesign() { cursor: "pointer", }} > - Sessions - {/* amicode pill spec (media/ui/atoms/pill.ts): round, currentColor - border, 10% currentColor tint — accent ink only when unseen. */} + Projects + {/* amicode#203: the entry is Projects now — the pill shows the + project count, or the unseen-session badge (accent ink) when + there are unread sessions. */} - {unseenTotal() > 0 ? unseenTotal() : totalSessions()} + {unseenTotal() > 0 ? unseenTotal() : projects().length}