Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d613c77
feat(app): Defaults capsule copy + drop Vaults tab from the global po…
kateebonner Jul 22, 2026
949d6f5
feat(app): project-first pure logic — slug, create-error classify, or…
kateebonner Jul 22, 2026
6c611f6
feat(server): POST /amicode/project — mkdir + best-effort git init (a…
kateebonner Jul 22, 2026
9181193
feat(app): New-project creation dialog wired to the dashboard (amicod…
kateebonner Jul 22, 2026
6a8c89c
feat(app): Projects entry rename + orphan sessions surfaced (amicode#…
kateebonner Jul 22, 2026
2032e7e
feat(app): Projects flyout shows the selected project's sessions; oth…
kateebonner Jul 22, 2026
0abe7bd
feat(app): Projects flyout = Recent (all projects) on top + project s…
kateebonner Jul 23, 2026
25dcdd2
feat(app): hide the extension's scaffold project from the dashboard (…
kateebonner Jul 23, 2026
24277f4
feat(app): drop the project counter from the Projects entry (amicode#…
kateebonner Jul 23, 2026
56848e1
fix(app): keep scaffold sessions visible; add per-project dedicated s…
kateebonner Jul 23, 2026
f432118
ux(app): remove the Projects counter/badge + the project name from th…
kateebonner Jul 23, 2026
1ae815c
fix(app): load ALL projects' sessions on the dashboard, not just the …
kateebonner Jul 23, 2026
ad54f26
fix(app): resolve sessions to projects by DIRECTORY, not the collapse…
kateebonner Jul 23, 2026
9151dfc
ux(app): sleek folder icon for projects; drop the session-row leading…
kateebonner Jul 23, 2026
60122e8
ux(app): consistent project/session row hierarchy in the flyout (amic…
kateebonner Jul 23, 2026
2c64b33
ux(app): Projects section on top; RECENT SESSIONS header matches PROJ…
kateebonner Jul 23, 2026
9bae2ad
ux(app): one chrome dropdown at a time; Connections popover padding m…
kateebonner Jul 23, 2026
44dfa56
fix(app): press-twice dropdown bug — announce + open must land atomic…
kateebonner Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions packages/app/src/components/amicode-defaults-capsule.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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()
Expand All @@ -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)
}
Expand Down Expand Up @@ -189,7 +202,7 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl
<button
type="button"
data-slot="amicode-defaults-face"
title="Session defaults — model & solver"
title="Defaults — model & solver, apply everywhere"
aria-expanded={open()}
onClick={toggle}
style={{
Expand Down Expand Up @@ -222,7 +235,7 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl
<div
data-slot="amicode-defaults-pop"
role="dialog"
aria-label="Session defaults"
aria-label="Defaults"
style={{
position: "absolute",
right: "0",
Expand Down
102 changes: 102 additions & 0 deletions packages/app/src/components/dialog-new-project.tsx
Original file line number Diff line number Diff line change
@@ -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<string | undefined>()
const slug = createMemo(() => projectNameToSlug(name()))

const createMutation = useMutation(() => ({
mutationFn: async (): Promise<void> => {
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 (
<Dialog title="New project" class="w-full max-w-[460px] mx-auto">
<form onSubmit={submit} class="flex flex-col gap-5 p-6 pt-0">
<div class="flex flex-col gap-2">
<TextField
autofocus
type="text"
label="Project name"
placeholder="Rydberg MIS"
value={name()}
onChange={setName}
/>
<Show
when={slug()}
fallback={<span class="text-12-regular text-text-weak">Creates a new folder in ~/AmicodeProjects.</span>}
>
<span class="text-12-regular text-text-weak">
Creates <span class="font-mono text-text-base">{slug()}</span> in ~/AmicodeProjects, with git for change
tracking.
</span>
</Show>
</div>

<Show when={error()}>
<div class="flex items-center gap-2" role="alert" aria-live="polite" data-slot="new-project-error">
<div class="size-1.5 rounded-full shrink-0 bg-icon-critical-base" />
<span class="text-12-regular text-text-weak">{error()}</span>
</div>
</Show>

<div class="flex items-center justify-between gap-3">
<button
type="button"
class="text-12-regular text-text-weak underline underline-offset-2 hover:text-text-base"
onClick={() => {
dialog.close()
props.onOpenExisting()
}}
>
Open existing folder instead…
</button>
<Button type="submit" disabled={!slug() || createMutation.isPending}>
{createMutation.isPending ? "Creating…" : "Create project"}
</Button>
</div>
</form>
</Dialog>
)
}
17 changes: 14 additions & 3 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -1370,7 +1371,13 @@ export const PromptInput: Component<PromptInputProps> = (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)
Expand Down Expand Up @@ -1428,7 +1435,9 @@ export const PromptInput: Component<PromptInputProps> = (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),
Expand Down Expand Up @@ -2081,7 +2090,9 @@ function ComposerPickerTrigger(props: ComponentProps<"button"> & { state: Compos
<Show when={local.state.icon}>
{(icon) => <Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />}
</Show>
<span class="min-w-0 truncate leading-5">{local.state.label}</span>
<Show when={local.state.label}>
<span class="min-w-0 truncate leading-5">{local.state.label}</span>
</Show>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</button>
)
Expand Down
67 changes: 41 additions & 26 deletions packages/app/src/components/status-popover-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -742,14 +742,19 @@ const amicodeHeaders = (conn: ServerConnection.Any) => {

// The two shared tab triggers/contents — rendered inside each mount's own
// <Tabs> so Kobalte's tabs context resolves normally.
function AmicodeStatusTabTriggers(props: { state: AmicodeStatusTabsState }) {
function AmicodeStatusTabTriggers(props: { state: AmicodeStatusTabsState; includeVaults?: boolean }) {
const language = useLanguage()
return (
<>
<Tabs.Trigger value="vaults" data-slot="tab" class="text-12-regular">
{props.state.vaultsCount() > 0 ? `${props.state.vaultsCount()} ` : ""}
{language.t("status.popover.tab.vaults")}
</Tabs.Trigger>
{/* 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). */}
<Show when={props.includeVaults ?? true}>
<Tabs.Trigger value="vaults" data-slot="tab" class="text-12-regular">
{props.state.vaultsCount() > 0 ? `${props.state.vaultsCount()} ` : ""}
{language.t("status.popover.tab.vaults")}
</Tabs.Trigger>
</Show>
<Tabs.Trigger value="connections" data-slot="tab" class="text-12-regular">
{props.state.connectionsCount() > 0 ? `${props.state.connectionsCount()} ` : ""}
{language.t("status.popover.tab.connections")}
Expand All @@ -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 (
<>
<Tabs.Content value="vaults">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<AmicodeVaultsTab
view={props.state.vaultsView()}
emptyLabel={language.t("dialog.vaults.empty")}
retryLabel={language.t("dialog.vaults.retry")}
onRetry={props.state.refetchVaults}
/>
<Button variant="secondary" class="mt-3 self-start h-8 px-3 py-1.5" onClick={props.state.onManageVaults}>
{language.t("status.popover.action.manageVaults")}
</Button>
<Show when={props.includeVaults ?? true}>
<Tabs.Content value="vaults">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<AmicodeVaultsTab
view={props.state.vaultsView()}
emptyLabel={language.t("dialog.vaults.empty")}
retryLabel={language.t("dialog.vaults.retry")}
onRetry={props.state.refetchVaults}
/>
<Button variant="secondary" class="mt-3 self-start h-8 px-3 py-1.5" onClick={props.state.onManageVaults}>
{language.t("status.popover.action.manageVaults")}
</Button>
</div>
</div>
</div>
</Tabs.Content>
</Tabs.Content>
</Show>

<Tabs.Content value="connections">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
{/* 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). */}
<div
class={
(props.includeVaults ?? true)
? "flex flex-col px-2 pb-2"
: "flex flex-col p-3.5"
}
>
<div class={(props.includeVaults ?? true) ? "flex flex-col p-3 bg-background-base rounded-sm min-h-14" : "flex flex-col min-h-14"}>
<AmicodeConnectionsTab
view={props.state.connectionsView()}
labels={props.state.connectionsLabels()}
Expand Down Expand Up @@ -829,10 +845,9 @@ export function StatusPopoverGlobalBody(props: {
defaultValue={GLOBAL_STATUS_DEFAULT_TAB}
variant="alt"
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<AmicodeStatusTabTriggers state={amicodeTabs} />
</Tabs.List>
<AmicodeStatusTabContents state={amicodeTabs} />
{/* amicode#203 (Kate): no tab-header — Connections is the only surface
here now (vaults moved to the sidebar), so the label is redundant. */}
<AmicodeStatusTabContents state={amicodeTabs} includeVaults={false} />
</Tabs>
</div>
)
Expand Down
13 changes: 7 additions & 6 deletions packages/app/src/components/status-popover-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/components/status-popover-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
17 changes: 15 additions & 2 deletions packages/app/src/components/status-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 (
<Popover
Expand Down
5 changes: 4 additions & 1 deletion packages/app/src/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import * as Sentry from "@sentry/solid"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app"
import { adoptHiddenProject } from "@/utils/amicode-hidden-project"
import { type Platform, PlatformProvider } from "@/context/platform"
import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
Expand Down Expand Up @@ -152,8 +153,9 @@ const getDefaultUrl = () => {

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)
}

Expand Down Expand Up @@ -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 = {
Expand Down
Loading
Loading