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/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 6bbeb616cb..fb88c7cc3e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -78,6 +78,7 @@ import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { promptPlaceholder } from "./prompt-input/placeholder" import { useDirectoryPicker } from "./directory-picker" import { showToast } from "@/utils/toast" +import { hiddenProjectWorktree } from "@/utils/amicode-hidden-project" import { ImagePreview } from "@opencode-ai/ui/image-preview" import { useQueries } from "@tanstack/solid-query" import { useQueryOptions } from "@/context/server-sync" @@ -1370,7 +1371,13 @@ export const PromptInput: Component = (props) => { })) const newSession = () => props.variant === "new-session" - const projects = createMemo(() => layout.projects.list()) + // amicode#203: hide the extension's scaffold project from the composer picker + // too (same filter as the dashboard). Only set in the amicode webview. + const projects = createMemo(() => { + const list = layout.projects.list() + const hidden = hiddenProjectWorktree() + return hidden ? list.filter((p) => p.worktree !== hidden) : list + }) const projectForDirectory = (directory: string | undefined) => { if (!directory) return const key = pathKey(directory) @@ -1428,7 +1435,9 @@ export const PromptInput: Component = (props) => { trigger: { action: "prompt-project", icon: "folder", - label: selectedProject() ? displayName(selectedProject()!) : language.t("session.new.project.new"), + // amicode#203 (Kate): don't show the project name in the chat — the folder + // icon + chevron keep the switcher reachable without naming the project. + label: "", class: "max-w-[203px]", style: control(), onPress: () => setPicker("projectOpen", true), @@ -2081,7 +2090,9 @@ function ComposerPickerTrigger(props: ComponentProps<"button"> & { state: Compos {(icon) => } - {local.state.label} + + {local.state.label} + ) 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

(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 } +} diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 61fafbe184..3500322149 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -19,12 +19,12 @@ import { createStore } from "solid-js/store" import { useQuery } from "@tanstack/solid-query" import { Button } from "@opencode-ai/ui/button" import { Logo } from "@opencode-ai/ui/logo" -import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { useTabs } from "@/context/tabs" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" -import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout" +import { useLayout, type LocalProject } from "@/context/layout" import { useNavigate } from "@solidjs/router" import { base64Encode } from "@opencode-ai/core/util/encode" import { Icon } from "@opencode-ai/ui/icon" @@ -35,14 +35,12 @@ import { useDirectoryPicker } from "@/components/directory-picker" import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server" import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2" import { ServerConnection, useServer } from "@/context/server" -import { sessionHasOpenTab, useTabs } from "@/context/tabs" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useNotification } from "@/context/notification" import { closeHomeProject, displayName, - getProjectAvatarSource, homeProjectDirectories, homeProjectNavigation, type HomeProjectSelection, @@ -50,8 +48,10 @@ import { sortedRootSessions, toggleHomeProjectSelection, } from "@/pages/layout/helpers" -import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" import { sessionTitle } from "@/utils/session-title" +import { showToast } from "@/utils/toast" +import { hiddenProjectWorktree } from "@/utils/amicode-hidden-project" +import { announceChromeDropdown, chromeDropdownOpenId, clearChromeDropdown } from "@/utils/chrome-dropdown" import { pathKey } from "@/utils/path-key" import { useGlobal } from "@/context/global" import { useCommand } from "@/context/command" @@ -90,10 +90,14 @@ import { AmicodeFooter } from "@opencode-ai/ui/amicode-footer" const HOME_SESSION_LIMIT = 64 const HOME_ROW_LAYOUT = "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none" -const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0` -const HOME_ROW = `${HOME_ROW_BASE} [font-weight:530] text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover` const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap" -const HOME_PROJECT_NAV_ROW = `${HOME_ROW_LAYOUT} h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:bg-v2-background-bg-layer-02 data-[selected]:text-v2-text-text-base data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-02 focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]` +// amicode#203: session rows share the project row's rhythm (h-7, px-1.5, rounded) +// but read as CHILDREN — lighter weight, muted until hover, and (when nested) +// indented to align under the project name. A plain overlay hover, vs the +// project row's bordered select, keeps the parent/child hierarchy legible. +const HOME_SESSION_ROW = `${HOME_ROW_LAYOUT} h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-base` +// amicode#203 (Kate): no border on hover/selected — background-only treatment. +const HOME_PROJECT_NAV_ROW = `${HOME_ROW_LAYOUT} h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base data-[selected]:bg-v2-background-bg-layer-02 data-[selected]:text-v2-text-text-base data-[selected]:hover:bg-v2-background-bg-layer-02 focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base` const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]" type HomeSessionRecord = { @@ -102,11 +106,6 @@ type HomeSessionRecord = { projectName: string } -type HomeSessionGroup = { - id: "today" | "yesterday" | "older" - title: string - sessions: HomeSessionRecord[] -} const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results" const HOME_SEARCH_RESULT_ROW = @@ -122,7 +121,6 @@ function buildHomeSessionRecords(input: { sync: Pick, "child"> projectDirectories: () => string[] projects: () => LocalProject[] - projectByID: () => Map }) { return [ ...new Map( @@ -134,12 +132,31 @@ 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: resolve by DIRECTORY, not projectID. opencode lumps every + // amicode directory under a single `global` project id, so id-based + // resolution (projectForSession's default) collapses all sessions onto one + // project. The dashboard's projects ARE directories, so an empty byID map + // forces projectForSession to match session.directory → project.worktree. + const project = projectForSession(session, input.projects(), new Map()) + // 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, - projectName: displayName(project), + // amicode#203: sessions in the extension's scaffold project show + // unlabeled (the scaffold is hidden from the switcher; its name never + // surfaces). Real projects keep their display name. + projectName: project.worktree === hiddenProjectWorktree() ? "" : displayName(project), } }) } @@ -210,6 +227,15 @@ function HomeDesign() { }) const focusedSync = () => focusedServerCtx()?.sync ?? sync const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list()) + // amicode#203: the project SWITCHER hides the extension's scaffold project + // (it's the server's cwd, not a user project) — but records()/projectDirectories + // still use the full `projects()`, so the sessions that land in the scaffold by + // default stay visible in Recent (labeled neutrally, see buildHomeSessionRecords). + // Only set in the amicode webview; standalone opencode is untouched. + const visibleProjects = createMemo(() => { + const hidden = hiddenProjectWorktree() + return hidden ? projects().filter((p) => p.worktree !== hidden) : projects() + }) const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.selection.directory)) const newSessionProject = createMemo( () => @@ -218,11 +244,11 @@ function HomeDesign() { projects()[0], ) const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])] - const projectDirectories = createMemo(() => { - const project = selectedProject() - if (!project) return projects().flatMap(directories) - return directories(project) - }) + // amicode#203: the dashboard shows ONE flat "all sessions" list PLUS per-project + // lists, so it must load EVERY project's sessions regardless of selection. + // Scoping to the selected project (the old behavior) made the flat list flip + // empty/populated with selection and hid non-selected projects' sessions. + const projectDirectories = createMemo(() => projects().flatMap(directories)) const search = createMemo(() => state.search.trim()) const sessionLoad = useQuery(() => ({ queryKey: ["home", "sessions", state.selection.server, ...projectDirectories()] as const, @@ -236,15 +262,11 @@ function HomeDesign() { }, })) - const projectByID = createMemo( - () => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))), - ) const allRecords = createMemo(() => buildHomeSessionRecords({ sync: focusedSync(), projectDirectories, projects, - projectByID, }), ) const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT)) @@ -254,7 +276,6 @@ function HomeDesign() { return allRecords().filter((record) => matchesHomeSessionSearch(record, query)) }) const searchOpen = createMemo(() => state.searchFocused && search().length > 0) - const groups = createMemo(() => groupSessions(records(), language)) const tabs = useTabs() // amicode: home-card data. All read from the focused server's /amicode/* raw @@ -377,7 +398,6 @@ function HomeDesign() { onCleanup(() => clearInterval(timer)) }) - const [sessionsExpanded, setSessionsExpanded] = createSignal(false) // Shared by the About-You card and the onboarding wizard: identity fields // ride query params on the raw POST route; refetch renders the saved state. async function saveProfileFields(fields: Record) { @@ -619,7 +639,22 @@ function HomeDesign() { // Chrome strip state (spec T3.3): sessions flyout. Grid editing is the // WidgetGrid's own affair now (uncontrolled — it renders its own customize). - const [sessionsOpen, setSessionsOpen] = createSignal(false) + const [sessionsOpen, setSessionsOpenRaw] = createSignal(false) + // amicode#203: one chrome dropdown at a time — announce on open, close when + // another announces. + const setSessionsOpen = (next: boolean) => { + // batch: the announce and the open flag must land atomically — otherwise + // the guard effect runs between them and instantly re-closes (the + // press-twice bug). + batch(() => { + if (next) announceChromeDropdown("projects") + else clearChromeDropdown("projects") + setSessionsOpenRaw(next) + }) + } + createEffect(() => { + if (chromeDropdownOpenId() !== "projects" && sessionsOpen()) setSessionsOpenRaw(false) + }) let flyoutRoot: HTMLDivElement | undefined createEffect(() => { if (!sessionsOpen()) return @@ -663,13 +698,6 @@ function HomeDesign() { document.removeEventListener("keydown", onKey) }) }) - const totalSessions = createMemo(() => groups().reduce((n, g) => n + g.sessions.length, 0)) - const unseenTotal = createMemo(() => { - const conn = focusedServer() - if (!conn) return 0 - return projects().reduce((n, p) => n + unseenCount(conn, p), 0) - }) - command.register("home", () => [ { id: "home.sessions.search.focus", @@ -765,7 +793,10 @@ function HomeDesign() { } function openSession(session: Session) { - const project = projectForSession(session, projects(), projectByID()) + // amicode#203: resolve by directory (empty byID) — projectID collapses to + // `global` across all amicode dirs, so id-resolution would open the wrong + // project's worktree. Navigation already uses session.directory. + const project = projectForSession(session, projects(), new Map()) const conn = focusedServer() if (!conn) return const directory = project?.worktree ?? session.directory @@ -790,6 +821,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(() => ) @@ -858,25 +913,8 @@ function HomeDesign() { cursor: "pointer", }} > - Sessions - {/* amicode pill spec (media/ui/atoms/pill.ts): round, currentColor - border, 10% currentColor tint — accent ink only when unseen. */} - 0 ? "var(--v2-text-text-accent)" : "var(--v2-text-text-muted)", - "font-variant-numeric": "tabular-nums", - }} - > - {unseenTotal() > 0 ? unseenTotal() : totalSessions()} - + Projects + {/* amicode#203: no counter/badge on the entry — just the label. */} @@ -911,14 +949,17 @@ function HomeDesign() { padding: "14px", }} > + {/* amicode#203 (Kate): Projects section at the top, then the + search + Recent-sessions list below. */} void chooseProject(conn)} + newProject={(conn) => void newProject(conn)} editProject={editProject} closeProject={(conn, directory) => { const next = closeHomeProject( @@ -931,6 +972,9 @@ function HomeDesign() { }} clearNotifications={clearNotifications} unseenCount={unseenCount} + sessionsFor={(worktree) => allRecords().filter((r) => r.project.worktree === worktree)} + onOpenSession={openSession} + activeServerKey={server.key} openSettings={openSettings} language={language} /> @@ -968,83 +1012,55 @@ function HomeDesign() { when={!sessionLoad.isLoading} fallback={} > - 0} - fallback={ -

- + {/* amicode#203 (Kate): header matches the PROJECTS + header — uppercase faint label + ghost icon button. */} +
+ + {language.t("sidebar.project.recentSessions")} + + + } + onClick={openNewSession} + aria-label={language.t("command.session.new")} /> -
- } - > - {(() => { - // 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 + +
+ 0} + fallback={ +
+ {language.t("home.sessions.empty")} +
} - return ( - <> - - {(group, index) => ( -
- -
- - {(record) => ( - - )} - -
-
- )} -
- 3}> - - - - ) - })()} -
+ > +
+ + {(record) => ( + + )} + +
+ +
@@ -1175,10 +1191,15 @@ 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 unseenCount: (server: ServerConnection.Any, project: LocalProject) => number + // amicode#203: per-project dedicated session lists (flow through to HomeProjectList) + sessionsFor: (worktree: string) => HomeSessionRecord[] + onOpenSession: (session: Session) => void + activeServerKey: ServerConnection.Key openSettings: () => void language: ReturnType }) { @@ -1246,7 +1267,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 +1291,7 @@ function HomeProjectColumn(props: { controller={controller} focusServer={props.focusServer} chooseProject={props.chooseProject} + newProject={props.newProject} openEdit={(server) => dialog.show(() => )} language={props.language} /> @@ -1306,6 +1328,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 +1373,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)} />
@@ -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}>
- {(project) => ( - - )} + {(project) => { + const key = ServerConnection.key(props.server) + const rowSelected = () => props.selected.server === key && props.selected.directory === project.worktree + const sessions = () => props.sessionsFor(project.worktree) + return ( +
+ + {/* 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 ( -
- - - -
- ) -} function HomeSessionSearch(props: { value: string @@ -1768,12 +1770,6 @@ function HomeSessionSearchResultRow(props: { onMouseEnter={() => props.onHighlight()} onClick={() => props.onSelect(props.record.session)} > -
void }) { - const language = useLanguage() - return ( -
- - - {(onNewSession) => ( - - {language.t("command.session.new")} - - )} - -
- ) -} function HomeSessionRow(props: { record: HomeSessionRecord server: ServerConnection.Key activeServer: boolean openSession: (session: Session) => void + /** nested under a project → the project name is redundant, so hide it */ + hideLabel?: boolean }) { const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) + const showLabel = () => !props.hideLabel && !!props.record.projectName return (