diff --git a/packages/app/src/components/amicode-defaults-capsule.tsx b/packages/app/src/components/amicode-defaults-capsule.tsx
index eae964f9eb..4a509f9757 100644
--- a/packages/app/src/components/amicode-defaults-capsule.tsx
+++ b/packages/app/src/components/amicode-defaults-capsule.tsx
@@ -1,6 +1,7 @@
-import { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
+import { Show, batch, createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
import { useModels } from "@/context/models"
import { AmicodeDefaultModel } from "./amicode-default-model"
+import { announceChromeDropdown, chromeDropdownOpenId, clearChromeDropdown } from "@/utils/chrome-dropdown"
import {
hpAfterConnect,
hpClickAction,
@@ -123,7 +124,15 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl
const close = () => {
setOpen(false)
setComputeOpen(false)
+ clearChromeDropdown("defaults")
}
+ // amicode#203: one chrome dropdown at a time (see chrome-dropdown.ts).
+ createEffect(() => {
+ if (chromeDropdownOpenId() !== "defaults" && open()) {
+ setOpen(false)
+ setComputeOpen(false)
+ }
+ })
const onDocPointer = (e: PointerEvent) => {
if (root && !root.contains(e.target as Node)) {
close()
@@ -138,7 +147,11 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl
}
const openPopover = () => {
if (open()) return
- setOpen(true)
+ // batch: announce + open flag must land atomically (press-twice bug).
+ batch(() => {
+ announceChromeDropdown("defaults")
+ setOpen(true)
+ })
document.addEventListener("pointerdown", onDocPointer, true)
document.addEventListener("keydown", onDocKey, true)
}
@@ -189,7 +202,7 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl
)
diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx
index 2f2abd192f..1822a9707c 100644
--- a/packages/app/src/components/status-popover-body.tsx
+++ b/packages/app/src/components/status-popover-body.tsx
@@ -742,14 +742,19 @@ const amicodeHeaders = (conn: ServerConnection.Any) => {
// The two shared tab triggers/contents — rendered inside each mount's own
// so Kobalte's tabs context resolves normally.
-function AmicodeStatusTabTriggers(props: { state: AmicodeStatusTabsState }) {
+function AmicodeStatusTabTriggers(props: { state: AmicodeStatusTabsState; includeVaults?: boolean }) {
const language = useLanguage()
return (
<>
-
- {props.state.vaultsCount() > 0 ? `${props.state.vaultsCount()} ` : ""}
- {language.t("status.popover.tab.vaults")}
-
+ {/* amicode#202: vaults moved to the native Armonia sidebar; the global
+ home-chrome mount passes includeVaults={false}. The session-view mount
+ keeps its vaults tab (default true). */}
+
+
+ {props.state.vaultsCount() > 0 ? `${props.state.vaultsCount()} ` : ""}
+ {language.t("status.popover.tab.vaults")}
+
+
{props.state.connectionsCount() > 0 ? `${props.state.connectionsCount()} ` : ""}
{language.t("status.popover.tab.connections")}
@@ -758,29 +763,40 @@ function AmicodeStatusTabTriggers(props: { state: AmicodeStatusTabsState }) {
)
}
-function AmicodeStatusTabContents(props: { state: AmicodeStatusTabsState }) {
+function AmicodeStatusTabContents(props: { state: AmicodeStatusTabsState; includeVaults?: boolean }) {
const language = useLanguage()
return (
<>
-
-
-
-
-
+
+
+
+
+
+
+
-
-
+
+
-
-
+ {/* amicode#203 (Kate): the header-less global mount uses a single flat
+ 14px padding (matching the other chrome dropdowns) instead of the
+ tabbed mount's inset card (px-2 pb-2 + p-3 raised box). */}
+
+
-
-
-
-
+ {/* amicode#203 (Kate): no tab-header — Connections is the only surface
+ here now (vaults moved to the sidebar), so the label is redundant. */}
+
)
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. */
diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx
index 5628f225a9..23823da927 100644
--- a/packages/app/src/components/status-popover.tsx
+++ b/packages/app/src/components/status-popover.tsx
@@ -3,7 +3,8 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Popover } from "@opencode-ai/ui/popover"
-import { Suspense, createMemo, createSignal, lazy, Show, type ComponentProps, type JSX } from "solid-js"
+import { Suspense, batch, createEffect, createMemo, createSignal, lazy, Show, type ComponentProps, type JSX } from "solid-js"
+import { announceChromeDropdown, chromeDropdownOpenId, clearChromeDropdown } from "@/utils/chrome-dropdown"
import { useLanguage } from "@/context/language"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
@@ -225,7 +226,19 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
// same module the session-header popover lazy-loads.
export function GlobalConnectionsPopover(props: { onManageVaults: () => void }) {
const language = useLanguage()
- const [shown, setShown] = createSignal(false)
+ const [shown, setShownRaw] = createSignal(false)
+ // amicode#203: one chrome dropdown at a time (see chrome-dropdown.ts).
+ const setShown = (next: boolean) => {
+ // batch: announce + open flag must land atomically (press-twice bug).
+ batch(() => {
+ if (next) announceChromeDropdown("connections")
+ else clearChromeDropdown("connections")
+ setShownRaw(next)
+ })
+ }
+ createEffect(() => {
+ if (chromeDropdownOpenId() !== "connections" && shown()) setShownRaw(false)
+ })
return (
{
const clearAuthToken = () => {
const params = new URLSearchParams(location.search)
- if (!params.has("auth_token")) return
+ if (!params.has("auth_token") && !params.has("amicode_hide_project")) return
params.delete("auth_token")
+ params.delete("amicode_hide_project")
history.replaceState(null, "", location.pathname + (params.size ? `?${params}` : "") + location.hash)
}
@@ -197,6 +199,7 @@ if (root instanceof HTMLElement) {
// Amicode webview: route ⌘V/⌘C/⌘X for every editable through the
// extension-host bridge (framed contexts only — self-gates unframed).
installGlobalClipboardFallback(window)
+ adoptHiddenProject(location.search) // amicode#203: hide the extension's scaffold project
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
clearAuthToken()
const server: ServerConnection.Http = {
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..4db2394635
--- /dev/null
+++ b/packages/app/src/pages/home-projects.test.ts
@@ -0,0 +1,103 @@
+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",
+ })
+ 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" })
+ })
+})
+
+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
- }
- >
- {(() => {
- // amicode: collapsed = the 3 most recent sessions across all
- // groups; "Show all (N)" expands. Pure derivation off the
- // existing groups() memo — no second data path to drift.
- const total = () => groups().reduce((n, g) => n + g.sessions.length, 0)
- const visible = () => {
- if (sessionsExpanded() || total() <= 3) return groups()
- let budget = 3
- const out: typeof groups extends () => infer G ? G : never = [] as never
- for (const g of groups()) {
- if (budget <= 0) break
- const take = g.sessions.slice(0, budget)
- budget -= take.length
- ;(out as { title: string; sessions: typeof g.sessions }[]).push({
- ...g,
- sessions: take,
- })
- }
- return out as ReturnType
+
+
@@ -1367,32 +1390,58 @@ function HomeProjectList(props: {
closeProject: (server: ServerConnection.Any, directory: string) => void
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
+ // amicode#203: each project owns a dedicated session list, shown nested when
+ // the project is selected. Flat "all sessions" lives above this (the flyout).
+ sessionsFor: (worktree: string) => HomeSessionRecord[]
+ onOpenSession: (session: Session) => void
+ activeServerKey: ServerConnection.Key
language: ReturnType
}) {
- // amicode: a single project is auto-focused, so its row under the brand
- // block is pure clutter (a stray "A amicode"). The list only renders once
- // there is an actual CHOICE; add-project stays in the header either way.
return (
- 1}>
+ 0}>
+
+ {/* selected project expands to its dedicated sessions; others
+ stay collapsed (Kate's model: one flat list above + per-project
+ lists here). */}
+ 0}>
+ {/* indent so session titles align under the project NAME (past
+ the folder icon + gap), reading as children of the project. */}
+
+
+ {(record) => (
+
+ )}
+
+
+
+
+ )
+ }}
@@ -1478,62 +1527,15 @@ function HomeProjectRow(props: {
)
}
-function HomeProjectAvatar(props: { project: LocalProject }) {
- const name = createMemo(() => displayName(props.project))
+function HomeProjectAvatar(_props: { project: LocalProject }) {
+ // amicode#203 (Kate): a sleek folder glyph, not the first-letter avatar.
return (
-
- )
-}
-
-function HomeSessionAvatar(props: { project: LocalProject; session: Session; activeServer: boolean }) {
- const directory = () => props.session.directory
- const sessionId = () => props.session.id
- const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
- // amicode: a quiet chevron for session rows (the brand mark everywhere was
- // too much; the working-spinner was removed too — one wedged run left it
- // spinning forever). Unread keeps its accent dot.
- return (
-
-
-
-
-
+
+
)
}
-function HomeSessionLeading(props: {
- project: LocalProject
- session: Session
- server: ServerConnection.Key
- activeServer: boolean
-}) {
- const tabs = useTabs()
- const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session))
- return (
-