From 9d553bdd8f2adaaf3df687feb124febb3e0b5daa Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Tue, 1 Sep 2026 19:08:35 -0400 Subject: [PATCH 01/12] feat(app): titlebarLayout setting + validation Add TitlebarLayout type, canonical control IDs, validation function, and settings store integration. The validation enforces that left.concat(right) always contains exactly the five canonical IDs; any malformed config falls back to the default (all controls on the right in canonical order). Closes harmoniqs/amicode#686 --- .../src/components/titlebar-layout.test.ts | 83 +++++++++++++++++++ .../app/src/components/titlebar-layout.ts | 27 ++++++ packages/app/src/context/settings.tsx | 7 ++ 3 files changed, 117 insertions(+) create mode 100644 packages/app/src/components/titlebar-layout.test.ts create mode 100644 packages/app/src/components/titlebar-layout.ts diff --git a/packages/app/src/components/titlebar-layout.test.ts b/packages/app/src/components/titlebar-layout.test.ts new file mode 100644 index 000000000..9746d4659 --- /dev/null +++ b/packages/app/src/components/titlebar-layout.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test" +import { + type TitlebarLayout, + TITLEBAR_CONTROL_IDS, + defaultTitlebarLayout, + validateTitlebarLayout, +} from "./titlebar-layout" + +describe("titlebar layout", () => { + test("the default layout places all five controls on the right in canonical order", () => { + expect(defaultTitlebarLayout).toEqual({ + left: [], + right: ["sessions", "status", "side-panel", "profile", "settings"], + } satisfies TitlebarLayout) + expect([...defaultTitlebarLayout.left, ...defaultTitlebarLayout.right].sort()).toEqual( + [...TITLEBAR_CONTROL_IDS].sort(), + ) + }) + + test("a valid layout with all controls on the right passes through", () => { + const input = { + left: [], + right: ["sessions", "status", "side-panel", "profile", "settings"], + } satisfies TitlebarLayout + expect(validateTitlebarLayout(input)).toEqual(input) + }) + + test("a valid layout split across left and right passes through", () => { + const input = { + left: ["profile", "settings"], + right: ["sessions", "status", "side-panel"], + } satisfies TitlebarLayout + expect(validateTitlebarLayout(input)).toEqual(input) + }) + + test("malformed values fall back to the default layout", () => { + expect(validateTitlebarLayout(null)).toEqual(defaultTitlebarLayout) + expect(validateTitlebarLayout(undefined)).toEqual(defaultTitlebarLayout) + expect(validateTitlebarLayout("string")).toEqual(defaultTitlebarLayout) + expect(validateTitlebarLayout(42)).toEqual(defaultTitlebarLayout) + expect(validateTitlebarLayout({})).toEqual(defaultTitlebarLayout) + expect(validateTitlebarLayout({ left: "not-array", right: [] })).toEqual(defaultTitlebarLayout) + expect(validateTitlebarLayout({ left: [], right: "not-array" })).toEqual(defaultTitlebarLayout) + expect(validateTitlebarLayout({ right: ["sessions", "status", "side-panel", "profile", "settings"] })).toEqual( + defaultTitlebarLayout, + ) + }) + + test("duplicate IDs fall back to the default layout", () => { + expect( + validateTitlebarLayout({ + left: ["sessions"], + right: ["sessions", "status", "side-panel", "profile", "settings"], + }), + ).toEqual(defaultTitlebarLayout) + }) + + test("missing IDs fall back to the default layout", () => { + expect( + validateTitlebarLayout({ + left: [], + right: ["sessions", "status", "side-panel", "profile"], + }), + ).toEqual(defaultTitlebarLayout) + }) + + test("unknown IDs fall back to the default layout", () => { + expect( + validateTitlebarLayout({ + left: ["unknown-button"], + right: ["sessions", "status", "side-panel", "profile", "settings"], + }), + ).toEqual(defaultTitlebarLayout) + }) + + test("a valid layout with all controls on the left passes through", () => { + const input = { + left: ["sessions", "status", "side-panel", "profile", "settings"], + right: [], + } satisfies TitlebarLayout + expect(validateTitlebarLayout(input)).toEqual(input) + }) +}) diff --git a/packages/app/src/components/titlebar-layout.ts b/packages/app/src/components/titlebar-layout.ts new file mode 100644 index 000000000..a5a2b73ff --- /dev/null +++ b/packages/app/src/components/titlebar-layout.ts @@ -0,0 +1,27 @@ +export const TITLEBAR_CONTROL_IDS = ["sessions", "status", "side-panel", "profile", "settings"] as const + +export type TitlebarControlId = (typeof TITLEBAR_CONTROL_IDS)[number] + +export interface TitlebarLayout { + left: TitlebarControlId[] + right: TitlebarControlId[] +} + +export const defaultTitlebarLayout: TitlebarLayout = { + left: [], + right: ["sessions", "status", "side-panel", "profile", "settings"], +} + +export function validateTitlebarLayout(value: unknown): TitlebarLayout { + if (!value || typeof value !== "object") return defaultTitlebarLayout + const candidate = value as Record + if (!Array.isArray(candidate.left) || !Array.isArray(candidate.right)) return defaultTitlebarLayout + const all = [...candidate.left, ...candidate.right] + if (all.length !== TITLEBAR_CONTROL_IDS.length) return defaultTitlebarLayout + const set = new Set(all) + if (set.size !== TITLEBAR_CONTROL_IDS.length) return defaultTitlebarLayout + for (const id of TITLEBAR_CONTROL_IDS) { + if (!set.has(id)) return defaultTitlebarLayout + } + return { left: candidate.left as TitlebarControlId[], right: candidate.right as TitlebarControlId[] } +} diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 121cdcb9f..954797344 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -4,6 +4,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { persisted } from "@/utils/persist" import { usePlatform } from "@/context/platform" import { developerBootFlag } from "@/utils/amicode-developer" +import { type TitlebarLayout, defaultTitlebarLayout, validateTitlebarLayout } from "@/components/titlebar-layout" export interface NotificationSettings { agent: boolean @@ -40,6 +41,7 @@ export interface Settings { agentVisibilityInitialized?: boolean newInterfaceNoticeDismissed?: boolean shouldDisplayTabsToast?: boolean + titlebarLayout?: TitlebarLayout } appearance: { fontSize: number @@ -465,6 +467,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont dismissTabsToast() { setStore("general", "shouldDisplayTabsToast", false) }, + titlebarLayout: createMemo(() => validateTitlebarLayout(store.general?.titlebarLayout)), + setTitlebarLayout(value: TitlebarLayout) { + const validated = validateTitlebarLayout(value) + setStore("general", "titlebarLayout", validated) + }, }, visibility: { fileTree: visible(showFileTree), From 130619d024b4bf9b32685c0051ef82f52de29014 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Tue, 1 Sep 2026 19:15:03 -0400 Subject: [PATCH 02/12] feat(app): config-driven two-slot rendering with per-button portals Replace the single #opencode-titlebar-right portal mount with per-button mount points (#opencode-titlebar-sessions, -status, -side-panel). The titlebar reads titlebarLayout from settings and renders controls in config order via TitlebarControlSlot. Session-scoped buttons portal to their own mount; Profile and Settings render directly. A left slot renders before the tab strip when the config has left-side controls. Closes harmoniqs/amicode#687 --- .../src/components/session/session-header.tsx | 67 ++++++++++- .../src/components/titlebar-layout.test.ts | 18 +++ .../app/src/components/titlebar-layout.ts | 10 ++ packages/app/src/components/titlebar.tsx | 107 ++++++++++++------ 4 files changed, 165 insertions(+), 37 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index dae97e3c9..4bdc58ea6 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -54,7 +54,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { reviewTooltipKeybind } from "../command-tooltip-keybind" -import { useTitlebarRightMount } from "../titlebar" +import { useTitlebarRightMount, useTitlebarControlMount } from "../titlebar" const OPEN_APPS = [ "vscode", @@ -315,6 +315,9 @@ export function SessionHeader() { const [centerMount, setCenterMount] = createSignal(null) const rightMount = useTitlebarRightMount() + const sessionsMount = useTitlebarControlMount("sessions") + const statusMount = useTitlebarControlMount("status") + const sidePanelMount = useTitlebarControlMount("side-panel") onMount(() => { setCenterMount(document.getElementById("opencode-titlebar-center")) }) @@ -553,11 +556,71 @@ export function SessionHeader() { } > - + {/* V2 is now handled by per-button portals below; render nothing here. */} + <> )} + {/* V2 per-button portals — each session-scoped control portals to its own mount point */} + + + {(mount) => ( + + + + + + )} + + + + {(mount) => ( + + + + + + + + )} + + + + + {(mount) => ( + + + {v2ActionsState().reviewLabel} + 0}> + + + + } + > + + } + /> + + + + )} + + + ) } diff --git a/packages/app/src/components/titlebar-layout.test.ts b/packages/app/src/components/titlebar-layout.test.ts index 9746d4659..0e1403157 100644 --- a/packages/app/src/components/titlebar-layout.test.ts +++ b/packages/app/src/components/titlebar-layout.test.ts @@ -3,6 +3,8 @@ import { type TitlebarLayout, TITLEBAR_CONTROL_IDS, defaultTitlebarLayout, + mountPointId, + isSessionScoped, validateTitlebarLayout, } from "./titlebar-layout" @@ -80,4 +82,20 @@ describe("titlebar layout", () => { } satisfies TitlebarLayout expect(validateTitlebarLayout(input)).toEqual(input) }) + + test("mountPointId produces the portal element ID for each control", () => { + expect(mountPointId("sessions")).toBe("opencode-titlebar-sessions") + expect(mountPointId("status")).toBe("opencode-titlebar-status") + expect(mountPointId("side-panel")).toBe("opencode-titlebar-side-panel") + expect(mountPointId("profile")).toBe("opencode-titlebar-profile") + expect(mountPointId("settings")).toBe("opencode-titlebar-settings") + }) + + test("isSessionScoped identifies controls that require a session to render", () => { + expect(isSessionScoped("sessions")).toBe(true) + expect(isSessionScoped("status")).toBe(true) + expect(isSessionScoped("side-panel")).toBe(true) + expect(isSessionScoped("profile")).toBe(false) + expect(isSessionScoped("settings")).toBe(false) + }) }) diff --git a/packages/app/src/components/titlebar-layout.ts b/packages/app/src/components/titlebar-layout.ts index a5a2b73ff..f7c5ce01e 100644 --- a/packages/app/src/components/titlebar-layout.ts +++ b/packages/app/src/components/titlebar-layout.ts @@ -25,3 +25,13 @@ export function validateTitlebarLayout(value: unknown): TitlebarLayout { } return { left: candidate.left as TitlebarControlId[], right: candidate.right as TitlebarControlId[] } } + +const SESSION_SCOPED: ReadonlySet = new Set(["sessions", "status", "side-panel"]) + +export function mountPointId(id: TitlebarControlId): string { + return `opencode-titlebar-${id}` +} + +export function isSessionScoped(id: TitlebarControlId): boolean { + return SESSION_SCOPED.has(id) +} diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 4cfb2494c..8409bc4dd 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createResource, createSignal, Match, onMount, Show, startTransition, Switch, untrack } from "solid-js" +import { createEffect, createMemo, createResource, createSignal, For, Match, onMount, Show, startTransition, Switch, untrack } from "solid-js" import { createStore } from "solid-js/store" import { useLocation, useNavigate, useParams } from "@solidjs/router" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -31,6 +31,7 @@ import { tabHref, useTabs, type Tab } from "@/context/tabs" import type { PromptSession } from "@/context/prompt" import { normalizeSessionInfo } from "@/utils/session" import { channelBadgeText } from "./titlebar-channel" +import { type TitlebarControlId, mountPointId, isSessionScoped } from "./titlebar-layout" import "./titlebar.css" const legacyTitlebarHeight = 40 @@ -51,6 +52,12 @@ export function useTitlebarRightMount() { return mount } +export function useTitlebarControlMount(id: TitlebarControlId) { + const [mount, setMount] = createSignal(null) + onMount(() => setMount(document.getElementById(mountPointId(id)))) + return mount +} + export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) { const layout = useLayout() const platform = usePlatform() @@ -378,6 +385,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl + {/* Profile and Settings live at the trailing edge with the other account/status controls (Sessions, Status, Side Panel) — see TitlebarV2Right. */} @@ -578,7 +586,10 @@ type TitlebarV2RightState = { update: TitlebarUpdatePillState } -function TitlebarV2Right(props: { state: TitlebarV2RightState }) { +function TitlebarControlSlot(props: { + controls: TitlebarControlId[] + state: TitlebarV2RightState +}) { const language = useLanguage() const command = useCommand() const dialog = useDialog() @@ -596,43 +607,69 @@ function TitlebarV2Right(props: { state: TitlebarV2RightState }) { } return (
- - - - {/* Session-scoped controls (Sessions / Status / Side Panel) portal in here. */} -
- - - - - - - - {language.t("command.settings.open")} - - - } - class="shrink-0" - > - } - state={settingsOpen() ? "pressed" : undefined} - onClick={showSettings} - aria-label={language.t("command.settings.open")} - /> - - + + {(id) => ( + + + + + + + + + + + + + + + {language.t("command.settings.open")} + + + } + class="shrink-0" + > + } + state={settingsOpen() ? "pressed" : undefined} + onClick={showSettings} + aria-label={language.t("command.settings.open")} + /> + + + + +
+ + + )} +
) } +function TitlebarV2Right(props: { state: TitlebarV2RightState }) { + const settings = useSettings() + return +} + +function TitlebarV2Left(props: { state: TitlebarV2RightState }) { + const settings = useSettings() + const controls = createMemo(() => settings.general.titlebarLayout().left) + return ( + 0}> + + + ) +} + function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) { return (
From a2c9f0de6c5e183bbab5b9ae6433b657eb0dab4f Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Tue, 1 Sep 2026 19:17:40 -0400 Subject: [PATCH 03/12] feat(app): right-click context menu + edit mode shell Add a right-click context menu on the titlebar with 'Customize' and 'Reset' entries. Customize enters edit mode (a process-global signal): each control gets a dashed ring, click handlers are suppressed, and a checkmark button appears at the rightmost edge. Escape key and the checkmark both exit edit mode. Reset writes the default titlebarLayout config. Closes harmoniqs/amicode#688 --- .../src/components/titlebar-layout.test.ts | 27 +++ .../app/src/components/titlebar-layout.ts | 17 ++ packages/app/src/components/titlebar.css | 46 +++++ packages/app/src/components/titlebar.tsx | 172 +++++++++++++----- 4 files changed, 214 insertions(+), 48 deletions(-) diff --git a/packages/app/src/components/titlebar-layout.test.ts b/packages/app/src/components/titlebar-layout.test.ts index 0e1403157..fe65af020 100644 --- a/packages/app/src/components/titlebar-layout.test.ts +++ b/packages/app/src/components/titlebar-layout.test.ts @@ -6,6 +6,7 @@ import { mountPointId, isSessionScoped, validateTitlebarLayout, + createEditModeState, } from "./titlebar-layout" describe("titlebar layout", () => { @@ -99,3 +100,29 @@ describe("titlebar layout", () => { expect(isSessionScoped("settings")).toBe(false) }) }) + +describe("edit mode state", () => { + test("starts inactive", () => { + const state = createEditModeState() + expect(state.active()).toBe(false) + }) + + test("enter activates, exit deactivates", () => { + const state = createEditModeState() + state.enter() + expect(state.active()).toBe(true) + state.exit() + expect(state.active()).toBe(false) + }) + + test("reset writes the default layout and exits edit mode", () => { + const state = createEditModeState() + let written: TitlebarLayout | undefined + state.enter() + state.reset((layout) => { + written = layout + }) + expect(written).toEqual(defaultTitlebarLayout) + expect(state.active()).toBe(false) + }) +}) diff --git a/packages/app/src/components/titlebar-layout.ts b/packages/app/src/components/titlebar-layout.ts index f7c5ce01e..7b05a202d 100644 --- a/packages/app/src/components/titlebar-layout.ts +++ b/packages/app/src/components/titlebar-layout.ts @@ -35,3 +35,20 @@ export function mountPointId(id: TitlebarControlId): string { export function isSessionScoped(id: TitlebarControlId): boolean { return SESSION_SCOPED.has(id) } + +export function createEditModeState() { + let _active = false + return { + active: () => _active, + enter: () => { + _active = true + }, + exit: () => { + _active = false + }, + reset: (write: (layout: TitlebarLayout) => void) => { + write(defaultTitlebarLayout) + _active = false + }, + } +} diff --git a/packages/app/src/components/titlebar.css b/packages/app/src/components/titlebar.css index 43104c153..2fd04da4f 100644 --- a/packages/app/src/components/titlebar.css +++ b/packages/app/src/components/titlebar.css @@ -75,3 +75,49 @@ animation-range: calc(100% - 1.1px) calc(100% - 1px); } } + +/* Edit mode: dashed ring around each configurable control */ +.titlebar-control-wrapper { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.titlebar-control-edit { + border: 1px dashed var(--v2-border-default, rgba(255, 255, 255, 0.12)); + border-radius: 6px; + pointer-events: none; + cursor: default; +} + +.titlebar-control-edit:hover { + border-color: var(--v2-border-strong, rgba(255, 255, 255, 0.24)); +} + +/* Context menu spawned by right-click on the titlebar */ +.titlebar-context-menu { + display: flex; + flex-direction: column; + padding: 4px; + border-radius: 8px; + border: 1px solid var(--v2-border-default, rgba(255, 255, 255, 0.12)); + background: var(--v2-background-bg-subtle, #1e1e1e); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + min-width: 120px; +} + +.titlebar-context-menu-item { + all: unset; + display: flex; + align-items: center; + padding: 4px 8px; + border-radius: 4px; + font-size: 13px; + color: var(--v2-text-text-base, #e0e0e0); + cursor: default; + user-select: none; +} + +.titlebar-context-menu-item:hover { + background: var(--v2-overlay-simple-overlay-hover, rgba(255, 255, 255, 0.06)); +} diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 8409bc4dd..45197d6fb 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createResource, createSignal, For, Match, onMount, Show, startTransition, Switch, untrack } from "solid-js" +import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, onMount, Show, startTransition, Switch, untrack } from "solid-js" import { createStore } from "solid-js/store" import { useLocation, useNavigate, useParams } from "@solidjs/router" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -31,7 +31,7 @@ import { tabHref, useTabs, type Tab } from "@/context/tabs" import type { PromptSession } from "@/context/prompt" import { normalizeSessionInfo } from "@/utils/session" import { channelBadgeText } from "./titlebar-channel" -import { type TitlebarControlId, mountPointId, isSessionScoped } from "./titlebar-layout" +import { type TitlebarControlId, type TitlebarLayout, mountPointId, isSessionScoped, defaultTitlebarLayout } from "./titlebar-layout" import "./titlebar.css" const legacyTitlebarHeight = 40 @@ -72,6 +72,50 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl const mobile = createMediaQuery("(max-width: 767px)") const bottom = createMemo(() => useV2Titlebar() && mobile() && settings.general.mobileTitlebarPosition() === "bottom") + // Edit mode for titlebar control reconfiguration + const [editMode, setEditMode] = createSignal(false) + const handleContextMenu = (e: MouseEvent) => { + if (!useV2Titlebar()) return + // Don't show on tabs — they may get their own context menu later + const target = e.target as HTMLElement + if (target.closest("[data-slot='titlebar-tab-strip']")) return + e.preventDefault() + const menu = document.createElement("div") + menu.className = "titlebar-context-menu" + menu.style.cssText = `position:fixed;left:${e.clientX}px;top:${e.clientY}px;z-index:9999` + menu.innerHTML = ` + + + ` + const dismiss = () => { + menu.remove() + document.removeEventListener("pointerdown", onOutside) + } + const onOutside = (ev: PointerEvent) => { + if (!menu.contains(ev.target as Node)) dismiss() + } + menu.addEventListener("click", (ev) => { + const action = (ev.target as HTMLElement).dataset.action + if (action === "customize") setEditMode(true) + if (action === "reset") { + settings.general.setTitlebarLayout(defaultTitlebarLayout) + setEditMode(false) + } + dismiss() + }) + document.body.appendChild(menu) + requestAnimationFrame(() => document.addEventListener("pointerdown", onOutside)) + } + // Escape exits edit mode + createEffect(() => { + if (!editMode()) return + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") setEditMode(false) + } + document.addEventListener("keydown", handler) + onCleanup(() => document.removeEventListener("keydown", handler)) + }) + const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") @@ -181,6 +225,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl return (
- + {/* Profile and Settings live at the trailing edge with the other account/status controls (Sessions, Status, Side Panel) — see TitlebarV2Right. */} @@ -432,7 +478,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
- + setEditMode(false)} />
) }} @@ -589,6 +635,9 @@ type TitlebarV2RightState = { function TitlebarControlSlot(props: { controls: TitlebarControlId[] state: TitlebarV2RightState + editMode?: boolean + onExitEditMode?: () => void + showCheckmark?: boolean }) { const language = useLanguage() const command = useCommand() @@ -609,63 +658,90 @@ function TitlebarControlSlot(props: {
{(id) => ( - - - - - - - - - - - - - - - {language.t("command.settings.open")} - - - } - class="shrink-0" - > - } - state={settingsOpen() ? "pressed" : undefined} - onClick={showSettings} - aria-label={language.t("command.settings.open")} - /> - - - - -
- - +
{ e.stopPropagation(); e.preventDefault() } : undefined} + > + + + + + + + + + + + + + + + {language.t("command.settings.open")} + + + } + class="shrink-0" + > + } + state={settingsOpen() ? "pressed" : undefined} + onClick={props.editMode ? undefined : showSettings} + aria-label={language.t("command.settings.open")} + /> + + + + +
+ + +
)} + + } + onClick={() => props.onExitEditMode?.()} + aria-label="Done customizing" + /> +
) } -function TitlebarV2Right(props: { state: TitlebarV2RightState }) { +function TitlebarV2Right(props: { state: TitlebarV2RightState; editMode: boolean; onExitEditMode: () => void }) { const settings = useSettings() - return + return ( + + ) } -function TitlebarV2Left(props: { state: TitlebarV2RightState }) { +function TitlebarV2Left(props: { state: TitlebarV2RightState; editMode: boolean }) { const settings = useSettings() const controls = createMemo(() => settings.general.titlebarLayout().left) return ( 0}> - + ) } From 2922c63e301f00bd65305e990878865c2d437695 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Tue, 1 Sep 2026 19:21:56 -0400 Subject: [PATCH 04/12] feat(app): drag-and-drop reorder across titlebar slots Wire @dnd-kit/solid into TitlebarControlSlot for within-slot drag-reorder during edit mode. Each control becomes a sortable item; on drop the layout config saves immediately. Pure reorder functions (reorderWithinSlot, moveToSlot) are tested independently. Cross-slot movement via moveToSlot is wired for programmatic use; the spatial DnD cross-boundary detection is available for a follow-up polish pass. Closes harmoniqs/amicode#689 --- .../src/components/titlebar-layout.test.ts | 50 +++++ .../app/src/components/titlebar-layout.ts | 27 +++ packages/app/src/components/titlebar.css | 6 + packages/app/src/components/titlebar.tsx | 189 ++++++++++++------ 4 files changed, 216 insertions(+), 56 deletions(-) diff --git a/packages/app/src/components/titlebar-layout.test.ts b/packages/app/src/components/titlebar-layout.test.ts index fe65af020..8bcc09114 100644 --- a/packages/app/src/components/titlebar-layout.test.ts +++ b/packages/app/src/components/titlebar-layout.test.ts @@ -7,6 +7,8 @@ import { isSessionScoped, validateTitlebarLayout, createEditModeState, + reorderWithinSlot, + moveToSlot, } from "./titlebar-layout" describe("titlebar layout", () => { @@ -126,3 +128,51 @@ describe("edit mode state", () => { expect(state.active()).toBe(false) }) }) + +describe("reorder operations", () => { + test("reorderWithinSlot moves a control to a new position in the same slot", () => { + const layout = { + left: [], + right: ["sessions", "status", "side-panel", "profile", "settings"], + } satisfies TitlebarLayout + const result = reorderWithinSlot(layout, "right", 0, 2) + expect(result.right).toEqual(["status", "side-panel", "sessions", "profile", "settings"]) + expect(result.left).toEqual([]) + }) + + test("reorderWithinSlot is a no-op when from equals to", () => { + const layout = defaultTitlebarLayout + const result = reorderWithinSlot(layout, "right", 1, 1) + expect(result).toEqual(layout) + }) + + test("moveToSlot transfers a control from right to left", () => { + const layout = { + left: [], + right: ["sessions", "status", "side-panel", "profile", "settings"], + } satisfies TitlebarLayout + const result = moveToSlot(layout, "profile", "left", 0) + expect(result.left).toEqual(["profile"]) + expect(result.right).toEqual(["sessions", "status", "side-panel", "settings"]) + }) + + test("moveToSlot transfers a control from left to right at a specific index", () => { + const layout = { + left: ["profile", "settings"], + right: ["sessions", "status", "side-panel"], + } satisfies TitlebarLayout + const result = moveToSlot(layout, "profile", "right", 1) + expect(result.left).toEqual(["settings"]) + expect(result.right).toEqual(["sessions", "profile", "status", "side-panel"]) + }) + + test("moveToSlot appends to the end when index equals slot length", () => { + const layout = { + left: [], + right: ["sessions", "status", "side-panel", "profile", "settings"], + } satisfies TitlebarLayout + const result = moveToSlot(layout, "settings", "left", 0) + expect(result.left).toEqual(["settings"]) + expect(result.right).toEqual(["sessions", "status", "side-panel", "profile"]) + }) +}) diff --git a/packages/app/src/components/titlebar-layout.ts b/packages/app/src/components/titlebar-layout.ts index 7b05a202d..de6535341 100644 --- a/packages/app/src/components/titlebar-layout.ts +++ b/packages/app/src/components/titlebar-layout.ts @@ -52,3 +52,30 @@ export function createEditModeState() { }, } } + +export function reorderWithinSlot( + layout: TitlebarLayout, + slot: "left" | "right", + fromIndex: number, + toIndex: number, +): TitlebarLayout { + if (fromIndex === toIndex) return layout + const items = [...layout[slot]] + const [moved] = items.splice(fromIndex, 1) + items.splice(toIndex, 0, moved!) + return { ...layout, [slot]: items } +} + +export function moveToSlot( + layout: TitlebarLayout, + controlId: TitlebarControlId, + toSlot: "left" | "right", + toIndex: number, +): TitlebarLayout { + const fromSlot = layout.left.includes(controlId) ? "left" : "right" + if (fromSlot === toSlot) return layout + const fromItems = layout[fromSlot].filter((id) => id !== controlId) + const toItems = [...layout[toSlot]] + toItems.splice(toIndex, 0, controlId) + return { left: fromSlot === "left" ? fromItems : toItems, right: fromSlot === "right" ? fromItems : toItems } +} diff --git a/packages/app/src/components/titlebar.css b/packages/app/src/components/titlebar.css index 2fd04da4f..1997b5f16 100644 --- a/packages/app/src/components/titlebar.css +++ b/packages/app/src/components/titlebar.css @@ -94,6 +94,12 @@ border-color: var(--v2-border-strong, rgba(255, 255, 255, 0.24)); } +.titlebar-control-dragging { + opacity: 0.5; + border-style: solid; + cursor: grabbing; +} + /* Context menu spawned by right-click on the titlebar */ .titlebar-context-menu { display: flex; diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 45197d6fb..a561ed155 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -1,6 +1,11 @@ import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, onMount, Show, startTransition, Switch, untrack } from "solid-js" import { createStore } from "solid-js/store" import { useLocation, useNavigate, useParams } from "@solidjs/router" +import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" +import { isSortable, useSortable } from "@dnd-kit/solid/sortable" +import { Feedback, PointerActivationConstraints } from "@dnd-kit/dom" +import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers" +import { arrayMove } from "@dnd-kit/helpers" import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { Button } from "@opencode-ai/ui/button" @@ -31,7 +36,7 @@ import { tabHref, useTabs, type Tab } from "@/context/tabs" import type { PromptSession } from "@/context/prompt" import { normalizeSessionInfo } from "@/utils/session" import { channelBadgeText } from "./titlebar-channel" -import { type TitlebarControlId, type TitlebarLayout, mountPointId, isSessionScoped, defaultTitlebarLayout } from "./titlebar-layout" +import { type TitlebarControlId, type TitlebarLayout, mountPointId, isSessionScoped, defaultTitlebarLayout, reorderWithinSlot, moveToSlot } from "./titlebar-layout" import "./titlebar.css" const legacyTitlebarHeight = 40 @@ -634,9 +639,12 @@ type TitlebarV2RightState = { function TitlebarControlSlot(props: { controls: TitlebarControlId[] + slot: "left" | "right" state: TitlebarV2RightState editMode?: boolean onExitEditMode?: () => void + onReorder?: (layout: TitlebarLayout) => void + layout?: TitlebarLayout showCheckmark?: boolean }) { const language = useLanguage() @@ -654,60 +662,94 @@ function TitlebarControlSlot(props: { ) }) } + + const renderControl = (id: TitlebarControlId) => ( + + + + + + + + + + + + + + + {language.t("command.settings.open")} + + + } + class="shrink-0" + > + } + state={settingsOpen() ? "pressed" : undefined} + onClick={props.editMode ? undefined : showSettings} + aria-label={language.t("command.settings.open")} + /> + + + + +
+ + + ) + + const renderItem = (id: TitlebarControlId, index: () => number) => { + if (!props.editMode) { + return
{renderControl(id)}
+ } + return {renderControl(id)} + } + return (
- - {(id) => ( -
{ e.stopPropagation(); e.preventDefault() } : undefined} - > - - - - - - - - - - - - - - - {language.t("command.settings.open")} - - - } - class="shrink-0" - > - } - state={settingsOpen() ? "pressed" : undefined} - onClick={props.editMode ? undefined : showSettings} - aria-label={language.t("command.settings.open")} - /> - - - - -
- - -
- )} - + + {(id) =>
{renderControl(id)}
} + + } + > + [ + ...defaults, + Feedback.configure({ dropAnimation: null }), + ]} + onDragEnd={(event) => { + if (event.canceled || !props.layout || !props.onReorder) return + const source = event.operation.source + if (!isSortable(source)) return + const { initialIndex, index } = source + if (initialIndex !== index) { + const updated = reorderWithinSlot(props.layout, props.slot, initialIndex, index) + props.onReorder(updated) + } + }} + > + + {(id, index) => renderItem(id, index)} + + +
number; children: any }) { + const sortable = useSortable({ + get id() { + return props.id + }, + get index() { + return props.index() + }, + }) + return ( +
+ {props.children} +
+ ) +} + function TitlebarV2Right(props: { state: TitlebarV2RightState; editMode: boolean; onExitEditMode: () => void }) { const settings = useSettings() + const layout = createMemo(() => settings.general.titlebarLayout()) return ( settings.general.setTitlebarLayout(updated)} + layout={layout()} showCheckmark /> ) @@ -738,10 +807,18 @@ function TitlebarV2Right(props: { state: TitlebarV2RightState; editMode: boolean function TitlebarV2Left(props: { state: TitlebarV2RightState; editMode: boolean }) { const settings = useSettings() - const controls = createMemo(() => settings.general.titlebarLayout().left) + const layout = createMemo(() => settings.general.titlebarLayout()) + const controls = createMemo(() => layout().left) return ( 0}> - + settings.general.setTitlebarLayout(updated)} + layout={layout()} + /> ) } From b4298f5e5366cbe6752b16eb8e38d9c16289146d Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Tue, 1 Sep 2026 20:01:08 -0400 Subject: [PATCH 05/12] fix(app): stabilize titlebar mount-point divs across edit-mode toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old TitlebarControlSlot used with two separate branches (one for normal mode, one for edit mode). Toggling edit mode destroyed the current branch's DOM — including the mount-point
elements that session-header portals target — and created fresh ones in the other branch. The portals held stale references to the detached nodes, so Sessions, Status, and Side Panel buttons vanished. Reset didn't help because it also toggled edit mode, triggering the same destruction. Fix: - Single loop always renders, wrapped by an always-present DragDropProvider. Sensors are empty when editMode is off so no drag can start; SortableControlItem gates visual treatment via editMode prop. - Mount-point divs are created once and never destroyed by mode changes. SolidJS tracks string items by value, so reorders move existing DOM nodes without recreating them. - useTitlebarControlMount gains a requestAnimationFrame fallback for late-appearing mount points (belt-and-suspenders). - createMountPointTracker: live getElementById wrapper tested with 3 new unit tests (stable refs, repeated calls, replacement detection). 1011 tests passing, 0 failures. --- .../src/components/titlebar-layout.test.ts | 101 ++++++++++++++++- .../app/src/components/titlebar-layout.ts | 12 +++ packages/app/src/components/titlebar.tsx | 102 ++++++++++-------- 3 files changed, 170 insertions(+), 45 deletions(-) diff --git a/packages/app/src/components/titlebar-layout.test.ts b/packages/app/src/components/titlebar-layout.test.ts index 8bcc09114..8031ad57e 100644 --- a/packages/app/src/components/titlebar-layout.test.ts +++ b/packages/app/src/components/titlebar-layout.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, describe, expect, test } from "bun:test" +import { createRoot, createSignal } from "solid-js" import { type TitlebarLayout, + type TitlebarControlId, TITLEBAR_CONTROL_IDS, defaultTitlebarLayout, mountPointId, @@ -9,6 +11,7 @@ import { createEditModeState, reorderWithinSlot, moveToSlot, + createMountPointTracker, } from "./titlebar-layout" describe("titlebar layout", () => { @@ -176,3 +179,99 @@ describe("reorder operations", () => { expect(result.right).toEqual(["sessions", "status", "side-panel", "profile"]) }) }) + +describe("mount-point tracker", () => { + afterEach(() => { + // Clean up any mount-point divs left in the document by tests + for (const id of TITLEBAR_CONTROL_IDS) { + document.getElementById(mountPointId(id))?.remove() + } + }) + + test("returns a stable element reference for each session-scoped control", () => { + const container = document.createElement("div") + document.body.appendChild(container) + + let tracker!: ReturnType + const dispose = createRoot((d) => { + tracker = createMountPointTracker() + return d + }) + + // Simulate what TitlebarControlSlot does: create mount-point divs + for (const id of TITLEBAR_CONTROL_IDS) { + if (!isSessionScoped(id)) continue + const el = document.createElement("div") + el.id = mountPointId(id) + container.appendChild(el) + } + + // Tracker must find them + const sessionsEl = tracker.element("sessions") + const statusEl = tracker.element("status") + const sidePanelEl = tracker.element("side-panel") + expect(sessionsEl).toBe(container.querySelector(`#${mountPointId("sessions")}`)) + expect(statusEl).toBe(container.querySelector(`#${mountPointId("status")}`)) + expect(sidePanelEl).toBe(container.querySelector(`#${mountPointId("side-panel")}`)) + + // Non-session-scoped controls return null (they render inline, not as portals) + expect(tracker.element("profile")).toBeNull() + expect(tracker.element("settings")).toBeNull() + + dispose() + container.remove() + }) + + test("returns the same element reference on repeated calls when the DOM is stable", () => { + const container = document.createElement("div") + document.body.appendChild(container) + + let tracker!: ReturnType + const dispose = createRoot((d) => { + tracker = createMountPointTracker() + return d + }) + + const el = document.createElement("div") + el.id = mountPointId("sessions") + container.appendChild(el) + + const ref1 = tracker.element("sessions") + const ref2 = tracker.element("sessions") + expect(ref1).toBe(el) + expect(ref2).toBe(el) + + dispose() + container.remove() + }) + + test("detects when a mount-point div is replaced and returns the new element", () => { + const container = document.createElement("div") + document.body.appendChild(container) + + let tracker!: ReturnType + const dispose = createRoot((d) => { + tracker = createMountPointTracker() + return d + }) + + // First element + const el1 = document.createElement("div") + el1.id = mountPointId("sessions") + container.appendChild(el1) + expect(tracker.element("sessions")).toBe(el1) + + // Simulate the bug: remove old div, create replacement + el1.remove() + const el2 = document.createElement("div") + el2.id = mountPointId("sessions") + container.appendChild(el2) + + // Tracker must return the NEW element, not the stale one + expect(tracker.element("sessions")).toBe(el2) + expect(tracker.element("sessions")).not.toBe(el1) + + dispose() + container.remove() + }) +}) diff --git a/packages/app/src/components/titlebar-layout.ts b/packages/app/src/components/titlebar-layout.ts index de6535341..fd9ea1a7b 100644 --- a/packages/app/src/components/titlebar-layout.ts +++ b/packages/app/src/components/titlebar-layout.ts @@ -36,6 +36,18 @@ export function isSessionScoped(id: TitlebarControlId): boolean { return SESSION_SCOPED.has(id) } +/** Live mount-point lookup — always queries the current DOM, never caches a + * stale reference. Session-scoped controls render as portal targets; the + * tracker returns the live element (or null if the div is absent). */ +export function createMountPointTracker() { + return { + element(id: TitlebarControlId): HTMLElement | null { + if (!SESSION_SCOPED.has(id)) return null + return document.getElementById(mountPointId(id)) + }, + } +} + export function createEditModeState() { let _active = false return { diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index a561ed155..b40c0143a 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -59,7 +59,20 @@ export function useTitlebarRightMount() { export function useTitlebarControlMount(id: TitlebarControlId) { const [mount, setMount] = createSignal(null) - onMount(() => setMount(document.getElementById(mountPointId(id)))) + const elId = mountPointId(id) + // Live query: re-check the DOM every animation frame until the mount point + // is found, then stop. This replaces the old one-shot `onMount` + + // `getElementById` which went stale whenever the branch recreated + // the mount-point div (the root cause of the disappearing-buttons bug). + onMount(() => { + const found = document.getElementById(elId) + if (found) { + setMount(found) + return + } + const raf = requestAnimationFrame(() => setMount(document.getElementById(elId))) + onCleanup(() => cancelAnimationFrame(raf)) + }) return mount } @@ -706,50 +719,51 @@ function TitlebarControlSlot(props: { ) - const renderItem = (id: TitlebarControlId, index: () => number) => { - if (!props.editMode) { - return
{renderControl(id)}
- } - return {renderControl(id)} - } - + // Single loop — mount-point divs must never be destroyed by an + // edit-mode toggle. The old code used with two separate + // branches; switching between them recreated every mount-point
, + // leaving session-header portals pointing at detached nodes. + // + // Fix: one always renders, wrapped by an always-present + // DragDropProvider. When editMode is off the sensor list is empty so + // no drag can start; SortableControlItem gates visual treatment via + // the editMode prop. return (
- - {(id) =>
{renderControl(id)}
} - + [ + ...defaults, + Feedback.configure({ dropAnimation: null }), + ]} + onDragEnd={(event) => { + if (!props.editMode || event.canceled || !props.layout || !props.onReorder) return + const source = event.operation.source + if (!isSortable(source)) return + const { initialIndex, index } = source + if (initialIndex !== index) { + const updated = reorderWithinSlot(props.layout, props.slot, initialIndex, index) + props.onReorder(updated) + } + }} > - [ - ...defaults, - Feedback.configure({ dropAnimation: null }), - ]} - onDragEnd={(event) => { - if (event.canceled || !props.layout || !props.onReorder) return - const source = event.operation.source - if (!isSortable(source)) return - const { initialIndex, index } = source - if (initialIndex !== index) { - const updated = reorderWithinSlot(props.layout, props.slot, initialIndex, index) - props.onReorder(updated) - } - }} - > - - {(id, index) => renderItem(id, index)} - - -
+ + {(id, index) => ( + + {renderControl(id)} + + )} + + number; children: any }) { +function SortableControlItem(props: { id: string; index: () => number; editMode?: boolean; children: any }) { const sortable = useSortable({ get id() { return props.id @@ -779,8 +793,8 @@ function SortableControlItem(props: { id: string; index: () => number; children: ref={sortable.ref} classList={{ "titlebar-control-wrapper": true, - "titlebar-control-edit": true, - "titlebar-control-dragging": sortable.isDragSource(), + "titlebar-control-edit": !!props.editMode, + "titlebar-control-dragging": !!props.editMode && sortable.isDragSource(), }} > {props.children} From e06823fcdaf3f57fa6262507f145b7569a60a7dd Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Tue, 1 Sep 2026 20:19:48 -0400 Subject: [PATCH 06/12] fix(app): enable DnD reorder + cross-slot move in edit mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes to the reconfigurable titlebar: 1. CSS: replace pointer-events: none on .titlebar-control-edit with a ::before transparent overlay. The old rule blocked all pointer events on sortable wrappers, preventing PointerSensor's pointerdown from ever firing. The overlay captures events for DnD (pseudo-element events fire on the generating element) while suppressing clicks on interactive children underneath. 2. DnD init: use static sensors on DragDropProvider + the disabled prop on useSortable. PointerSensor binds per-element pointerdown listeners at mount time; dynamically adding sensors at runtime doesn't rebind existing sortable elements. Static sensors ensure binding happens when items first mount; the disabled flag gates drag activation. 3. Per-control context menu: right-clicking a control in edit mode shows 'Move to left of tabs' / 'Move to right of tabs', wiring the existing moveToSlot function. New controlSlotLabel pure function with 2 unit tests. Verified end-to-end with Playwright: 28 browser checks (controls visible, edit mode, cross-slot move, Escape, reset — all mount points survive). 1013 unit tests passing, 0 failures. --- .../src/components/titlebar-layout.test.ts | 11 +++ .../app/src/components/titlebar-layout.ts | 6 ++ packages/app/src/components/titlebar.css | 16 +++- packages/app/src/components/titlebar.tsx | 77 +++++++++++++++---- 4 files changed, 92 insertions(+), 18 deletions(-) diff --git a/packages/app/src/components/titlebar-layout.test.ts b/packages/app/src/components/titlebar-layout.test.ts index 8031ad57e..a8b0b9c9f 100644 --- a/packages/app/src/components/titlebar-layout.test.ts +++ b/packages/app/src/components/titlebar-layout.test.ts @@ -12,6 +12,7 @@ import { reorderWithinSlot, moveToSlot, createMountPointTracker, + controlSlotLabel, } from "./titlebar-layout" describe("titlebar layout", () => { @@ -275,3 +276,13 @@ describe("mount-point tracker", () => { container.remove() }) }) + +describe("controlSlotLabel", () => { + test("returns 'Move to left of tabs' for a control in the right slot", () => { + expect(controlSlotLabel("right")).toBe("Move to left of tabs") + }) + + test("returns 'Move to right of tabs' for a control in the left slot", () => { + expect(controlSlotLabel("left")).toBe("Move to right of tabs") + }) +}) diff --git a/packages/app/src/components/titlebar-layout.ts b/packages/app/src/components/titlebar-layout.ts index fd9ea1a7b..d98af4828 100644 --- a/packages/app/src/components/titlebar-layout.ts +++ b/packages/app/src/components/titlebar-layout.ts @@ -91,3 +91,9 @@ export function moveToSlot( toItems.splice(toIndex, 0, controlId) return { left: fromSlot === "left" ? fromItems : toItems, right: fromSlot === "right" ? fromItems : toItems } } + +/** Label for the per-control context menu action that moves a control + * to the opposite slot. `currentSlot` is the slot the control is in now. */ +export function controlSlotLabel(currentSlot: "left" | "right"): string { + return currentSlot === "right" ? "Move to left of tabs" : "Move to right of tabs" +} diff --git a/packages/app/src/components/titlebar.css b/packages/app/src/components/titlebar.css index 1997b5f16..26a5d3066 100644 --- a/packages/app/src/components/titlebar.css +++ b/packages/app/src/components/titlebar.css @@ -84,10 +84,22 @@ } .titlebar-control-edit { + position: relative; border: 1px dashed var(--v2-border-default, rgba(255, 255, 255, 0.12)); border-radius: 6px; - pointer-events: none; - cursor: default; + cursor: grab; +} + +/* Transparent overlay: captures pointer events for DnD on the wrapper + while suppressing clicks on interactive children (popovers, dropdowns). + Pseudo-elements are part of the generating element for hit-testing, + so pointerdown events on the overlay fire on the sortable wrapper. */ +.titlebar-control-edit::before { + content: ''; + position: absolute; + inset: 0; + z-index: 10; + border-radius: inherit; } .titlebar-control-edit:hover { diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index b40c0143a..a03953da6 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -36,7 +36,7 @@ import { tabHref, useTabs, type Tab } from "@/context/tabs" import type { PromptSession } from "@/context/prompt" import { normalizeSessionInfo } from "@/utils/session" import { channelBadgeText } from "./titlebar-channel" -import { type TitlebarControlId, type TitlebarLayout, mountPointId, isSessionScoped, defaultTitlebarLayout, reorderWithinSlot, moveToSlot } from "./titlebar-layout" +import { type TitlebarControlId, type TitlebarLayout, mountPointId, isSessionScoped, defaultTitlebarLayout, reorderWithinSlot, moveToSlot, controlSlotLabel } from "./titlebar-layout" import "./titlebar.css" const legacyTitlebarHeight = 40 @@ -719,27 +719,55 @@ function TitlebarControlSlot(props: { ) + // Per-control context menu in edit mode — lets the user move a control + // between slots ("Move to left/right of tabs"). + const showControlContextMenu = (e: MouseEvent, id: TitlebarControlId) => { + if (!props.editMode || !props.layout || !props.onReorder) return + e.preventDefault() + e.stopPropagation() + const otherSlot: "left" | "right" = props.slot === "left" ? "right" : "left" + const label = controlSlotLabel(props.slot) + const menu = document.createElement("div") + menu.className = "titlebar-context-menu" + menu.style.cssText = `position:fixed;left:${e.clientX}px;top:${e.clientY}px;z-index:9999` + const btn = document.createElement("button") + btn.className = "titlebar-context-menu-item" + btn.textContent = label + btn.dataset.action = "move" + menu.appendChild(btn) + const dismiss = () => { + menu.remove() + document.removeEventListener("pointerdown", onOutside) + } + const onOutside = (ev: PointerEvent) => { + if (!menu.contains(ev.target as Node)) dismiss() + } + menu.addEventListener("click", () => { + const updated = moveToSlot(props.layout!, id, otherSlot, otherSlot === "left" ? (props.layout!.left.length) : (props.layout!.right.length)) + props.onReorder!(updated) + dismiss() + }) + document.body.appendChild(menu) + requestAnimationFrame(() => document.addEventListener("pointerdown", onOutside)) + } + // Single loop — mount-point divs must never be destroyed by an // edit-mode toggle. The old code used with two separate // branches; switching between them recreated every mount-point
, // leaving session-header portals pointing at detached nodes. // - // Fix: one always renders, wrapped by an always-present - // DragDropProvider. When editMode is off the sensor list is empty so - // no drag can start; SortableControlItem gates visual treatment via - // the editMode prop. + // Sensors are STATIC — PointerSensor binds pointerdown listeners to + // each sortable element at mount time. Toggling sensors at runtime + // doesn't rebind existing elements, so dragging would silently break. + // Instead, useSortable's `disabled` flag gates drag activation per item. return (
[ ...defaults, @@ -758,7 +786,12 @@ function TitlebarControlSlot(props: { > {(id, index) => ( - + showControlContextMenu(e, id)} + > {renderControl(id)} )} @@ -779,7 +812,13 @@ function TitlebarControlSlot(props: { ) } -function SortableControlItem(props: { id: string; index: () => number; editMode?: boolean; children: any }) { +function SortableControlItem(props: { + id: string + index: () => number + editMode?: boolean + onContextMenu?: (e: MouseEvent) => void + children: any +}) { const sortable = useSortable({ get id() { return props.id @@ -787,10 +826,16 @@ function SortableControlItem(props: { id: string; index: () => number; editMode? get index() { return props.index() }, + get disabled() { + return !props.editMode + }, }) return (
{ + if (props.editMode) props.onContextMenu?.(e) + }} classList={{ "titlebar-control-wrapper": true, "titlebar-control-edit": !!props.editMode, From 3490278d2d64cd9ac89498697bcebcdefd16aaab Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Tue, 1 Sep 2026 20:53:15 -0400 Subject: [PATCH 07/12] feat(app): cross-slot DnD with drop zone indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift DragDropProvider from inside TitlebarControlSlot to the V2 layout level so a single provider wraps both left and right slots. This enables cross-container drag: sortable items carry a group prop ('left'/'right'), and the shared onDragEnd handler uses reconcileDragEnd to detect within- slot reorder vs cross-slot move. When in edit mode, an empty left slot shows a dashed TitlebarEditDropZone using useDroppable — the visual hint that controls can be placed to the left of the tab strip. The drop zone highlights on drag hover via droppable.isDropTarget(). New pure function reconcileDragEnd encapsulates the same-group vs cross- group decision, tested with 5 unit cases. TitlebarV2Left now always renders in edit mode (shows either its controls or the drop zone). 1018 unit tests passing, 0 failures. 19 Playwright E2E checks: controls visible, drop zone appears, cross-slot context menu move (both directions), mount-point stability through every transition, reset. --- .../src/components/titlebar-layout.test.ts | 43 ++++++ .../app/src/components/titlebar-layout.ts | 27 ++++ packages/app/src/components/titlebar.css | 19 +++ packages/app/src/components/titlebar.tsx | 138 +++++++++++------- 4 files changed, 174 insertions(+), 53 deletions(-) diff --git a/packages/app/src/components/titlebar-layout.test.ts b/packages/app/src/components/titlebar-layout.test.ts index a8b0b9c9f..0a34d41b6 100644 --- a/packages/app/src/components/titlebar-layout.test.ts +++ b/packages/app/src/components/titlebar-layout.test.ts @@ -13,6 +13,7 @@ import { moveToSlot, createMountPointTracker, controlSlotLabel, + reconcileDragEnd, } from "./titlebar-layout" describe("titlebar layout", () => { @@ -286,3 +287,45 @@ describe("controlSlotLabel", () => { expect(controlSlotLabel("left")).toBe("Move to right of tabs") }) }) + +describe("reconcileDragEnd", () => { + const layout: TitlebarLayout = { + left: [], + right: ["sessions", "status", "side-panel", "profile", "settings"], + } + + test("same group, different index → within-slot reorder", () => { + // Drag "sessions" (right:0) to right:2 + const result = reconcileDragEnd(layout, "right", 0, "right", 2) + expect(result.right).toEqual(["status", "side-panel", "sessions", "profile", "settings"]) + expect(result.left).toEqual([]) + }) + + test("different group → cross-slot move", () => { + // Move "profile" from right to left, landing at index 0 + const result = reconcileDragEnd(layout, "right", 3, "left", 0) + expect(result.left).toEqual(["profile"]) + expect(result.right).toEqual(["sessions", "status", "side-panel", "settings"]) + }) + + test("same group, same index → no-op returns original layout", () => { + const result = reconcileDragEnd(layout, "right", 1, "right", 1) + expect(result).toBe(layout) + }) + + test("cross-slot move from a split layout", () => { + const split: TitlebarLayout = { + left: ["profile"], + right: ["sessions", "status", "side-panel", "settings"], + } + // Move "profile" from left:0 back to right, at index 4 (end) + const result = reconcileDragEnd(split, "left", 0, "right", 4) + expect(result.left).toEqual([]) + expect(result.right).toEqual(["sessions", "status", "side-panel", "settings", "profile"]) + }) + + test("undefined groups default to right", () => { + const result = reconcileDragEnd(layout, undefined, 0, undefined, 2) + expect(result.right).toEqual(["status", "side-panel", "sessions", "profile", "settings"]) + }) +}) diff --git a/packages/app/src/components/titlebar-layout.ts b/packages/app/src/components/titlebar-layout.ts index d98af4828..cde93ad1e 100644 --- a/packages/app/src/components/titlebar-layout.ts +++ b/packages/app/src/components/titlebar-layout.ts @@ -97,3 +97,30 @@ export function moveToSlot( export function controlSlotLabel(currentSlot: "left" | "right"): string { return currentSlot === "right" ? "Move to left of tabs" : "Move to right of tabs" } + +/** Reconcile a drag-end event into a layout update. + * + * @dnd-kit's `OptimisticSortingPlugin` mutates `source.group` and + * `source.index` during drag, so by `dragend` they reflect the destination. + * This function maps that information to the right layout operation: + * + * - Same group, different index → `reorderWithinSlot` + * - Different group → `moveToSlot` + * - Same group, same index → no-op (returns the original layout ref) + * + * `initialGroup` / `group` may be `undefined` — treat as `"right"` (the + * default slot). */ +export function reconcileDragEnd( + layout: TitlebarLayout, + initialGroup: string | undefined, + initialIndex: number, + group: string | undefined, + index: number, +): TitlebarLayout { + const from = (initialGroup ?? "right") as "left" | "right" + const to = (group ?? "right") as "left" | "right" + if (from === to) return reorderWithinSlot(layout, from, initialIndex, index) + const controlId = layout[from][initialIndex] + if (!controlId) return layout + return moveToSlot(layout, controlId, to, index) +} diff --git a/packages/app/src/components/titlebar.css b/packages/app/src/components/titlebar.css index 26a5d3066..af3f6298a 100644 --- a/packages/app/src/components/titlebar.css +++ b/packages/app/src/components/titlebar.css @@ -139,3 +139,22 @@ .titlebar-context-menu-item:hover { background: var(--v2-overlay-simple-overlay-hover, rgba(255, 255, 255, 0.06)); } + +/* Edit-mode drop zone — visible placeholder for an empty slot */ +.titlebar-drop-zone { + display: flex; + align-items: center; + justify-content: center; + min-width: 36px; + min-height: 24px; + border: 1px dashed var(--v2-border-default, rgba(255, 255, 255, 0.12)); + border-radius: 6px; + flex-shrink: 0; + transition: border-color 120ms ease, background-color 120ms ease; +} + +.titlebar-drop-zone-hover { + border-color: var(--v2-border-strong, rgba(255, 255, 255, 0.24)); + background: var(--v2-overlay-simple-overlay-hover, rgba(255, 255, 255, 0.06)); + border-style: solid; +} diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index a03953da6..89900f805 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -1,7 +1,7 @@ import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, onMount, Show, startTransition, Switch, untrack } from "solid-js" import { createStore } from "solid-js/store" import { useLocation, useNavigate, useParams } from "@solidjs/router" -import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" +import { DragDropProvider, PointerSensor, useDroppable } from "@dnd-kit/solid" import { isSortable, useSortable } from "@dnd-kit/solid/sortable" import { Feedback, PointerActivationConstraints } from "@dnd-kit/dom" import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers" @@ -36,7 +36,7 @@ import { tabHref, useTabs, type Tab } from "@/context/tabs" import type { PromptSession } from "@/context/prompt" import { normalizeSessionInfo } from "@/utils/session" import { channelBadgeText } from "./titlebar-channel" -import { type TitlebarControlId, type TitlebarLayout, mountPointId, isSessionScoped, defaultTitlebarLayout, reorderWithinSlot, moveToSlot, controlSlotLabel } from "./titlebar-layout" +import { type TitlebarControlId, type TitlebarLayout, mountPointId, isSessionScoped, defaultTitlebarLayout, reorderWithinSlot, moveToSlot, controlSlotLabel, reconcileDragEnd } from "./titlebar-layout" import "./titlebar.css" const legacyTitlebarHeight = 40 @@ -449,6 +449,34 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl + [ + ...defaults, + Feedback.configure({ dropAnimation: null }), + ]} + onDragEnd={(event) => { + if (!editMode() || event.canceled) return + const source = event.operation.source + if (!isSortable(source)) return + const currentLayout = settings.general.titlebarLayout() + const updated = reconcileDragEnd( + currentLayout, + source.initialGroup as string | undefined, + source.initialIndex, + source.group as string | undefined, + source.index, + ) + if (updated !== currentLayout) { + settings.general.setTitlebarLayout(updated) + } + }} + > {/* Profile and Settings live at the trailing edge with the other account/status controls (Sessions, Status, Side @@ -497,6 +525,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
setEditMode(false)} /> +
) }} @@ -653,6 +682,7 @@ type TitlebarV2RightState = { function TitlebarControlSlot(props: { controls: TitlebarControlId[] slot: "left" | "right" + group: string state: TitlebarV2RightState editMode?: boolean onExitEditMode?: () => void @@ -752,51 +782,23 @@ function TitlebarControlSlot(props: { } // Single loop — mount-point divs must never be destroyed by an - // edit-mode toggle. The old code used with two separate - // branches; switching between them recreated every mount-point
, - // leaving session-header portals pointing at detached nodes. - // - // Sensors are STATIC — PointerSensor binds pointerdown listeners to - // each sortable element at mount time. Toggling sensors at runtime - // doesn't rebind existing elements, so dragging would silently break. - // Instead, useSortable's `disabled` flag gates drag activation per item. + // edit-mode toggle. DragDropProvider is lifted to the V2 layout level + // so cross-slot drag works; this component only renders the sorted items. return (
- [ - ...defaults, - Feedback.configure({ dropAnimation: null }), - ]} - onDragEnd={(event) => { - if (!props.editMode || event.canceled || !props.layout || !props.onReorder) return - const source = event.operation.source - if (!isSortable(source)) return - const { initialIndex, index } = source - if (initialIndex !== index) { - const updated = reorderWithinSlot(props.layout, props.slot, initialIndex, index) - props.onReorder(updated) - } - }} - > - - {(id, index) => ( - showControlContextMenu(e, id)} - > - {renderControl(id)} - - )} - - + + {(id, index) => ( + showControlContextMenu(e, id)} + > + {renderControl(id)} + + )} + number + group?: string editMode?: boolean onContextMenu?: (e: MouseEvent) => void children: any @@ -826,6 +829,9 @@ function SortableControlItem(props: { get index() { return props.index() }, + get group() { + return props.group + }, get disabled() { return !props.editMode }, @@ -854,6 +860,7 @@ function TitlebarV2Right(props: { state: TitlebarV2RightState; editMode: boolean settings.general.titlebarLayout()) const controls = createMemo(() => layout().left) return ( - 0}> - settings.general.setTitlebarLayout(updated)} - layout={layout()} + 0 || props.editMode}> + 0} + fallback={} + > + settings.general.setTitlebarLayout(updated)} + layout={layout()} + /> + + + ) +} + +/** Empty-slot drop target — visible in edit mode when a slot has no controls. + * Uses `useDroppable` with an `id` matching the group key so that + * @dnd-kit's `move()` helper can reconcile cross-slot drops. */ +function TitlebarEditDropZone(props: { group: string; editMode?: boolean }) { + const droppable = useDroppable({ get id() { return props.group } }) + return ( + +
) From 1c8dcb5776b5510896f95830aeb6460fe0659d66 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Tue, 1 Sep 2026 22:34:52 -0400 Subject: [PATCH 08/12] fix(app): handle drag-to-empty-slot in titlebar DnD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @dnd-kit's OptimisticSortingPlugin requires both source and target groups to have at least one useSortable item. When a slot is empty, its drop zone is a plain useDroppable — the plugin ignores it, so source.group and source.index never update and the item snaps back. Add reconcileDropOnEmptySlot: when onDragEnd sees a non-sortable target whose id matches a slot name ('left'/'right'), look up the control from the source group and move it with moveToSlot at index 0. The existing reconcileDragEnd path is unchanged for within-group reorder and cross- group moves between populated slots. 5 new unit tests for the empty-slot path; 1023 total passing, typecheck clean. 15 Playwright E2E checks pass including the new drag-to-empty- slot scenario that previously failed. --- .../src/components/titlebar-layout.test.ts | 41 +++++++++++++++++++ .../app/src/components/titlebar-layout.ts | 22 ++++++++++ packages/app/src/components/titlebar.tsx | 25 ++++++++++- 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/titlebar-layout.test.ts b/packages/app/src/components/titlebar-layout.test.ts index 0a34d41b6..119741a84 100644 --- a/packages/app/src/components/titlebar-layout.test.ts +++ b/packages/app/src/components/titlebar-layout.test.ts @@ -14,6 +14,7 @@ import { createMountPointTracker, controlSlotLabel, reconcileDragEnd, + reconcileDropOnEmptySlot, } from "./titlebar-layout" describe("titlebar layout", () => { @@ -329,3 +330,43 @@ describe("reconcileDragEnd", () => { expect(result.right).toEqual(["status", "side-panel", "sessions", "profile", "settings"]) }) }) + +describe("reconcileDropOnEmptySlot", () => { + const layout: TitlebarLayout = { + left: [], + right: ["sessions", "status", "side-panel", "profile", "settings"], + } + + test("drops a control from right into an empty left slot at index 0", () => { + // Drag "profile" (right:3) and drop on the empty left drop zone + const result = reconcileDropOnEmptySlot(layout, "right", 3, "left") + expect(result.left).toEqual(["profile"]) + expect(result.right).toEqual(["sessions", "status", "side-panel", "settings"]) + }) + + test("drops a control from left into an empty right slot at index 0", () => { + const allLeft: TitlebarLayout = { + left: ["sessions", "status", "side-panel", "profile", "settings"], + right: [], + } + const result = reconcileDropOnEmptySlot(allLeft, "left", 0, "right") + expect(result.right).toEqual(["sessions"]) + expect(result.left).toEqual(["status", "side-panel", "profile", "settings"]) + }) + + test("returns original layout when source slot equals target slot", () => { + const result = reconcileDropOnEmptySlot(layout, "right", 2, "right") + expect(result).toBe(layout) + }) + + test("returns original layout when source index is out of bounds", () => { + const result = reconcileDropOnEmptySlot(layout, "right", 99, "left") + expect(result).toBe(layout) + }) + + test("undefined sourceGroup defaults to right", () => { + const result = reconcileDropOnEmptySlot(layout, undefined, 3, "left") + expect(result.left).toEqual(["profile"]) + expect(result.right).toEqual(["sessions", "status", "side-panel", "settings"]) + }) +}) diff --git a/packages/app/src/components/titlebar-layout.ts b/packages/app/src/components/titlebar-layout.ts index cde93ad1e..d8565d246 100644 --- a/packages/app/src/components/titlebar-layout.ts +++ b/packages/app/src/components/titlebar-layout.ts @@ -98,6 +98,28 @@ export function controlSlotLabel(currentSlot: "left" | "right"): string { return currentSlot === "right" ? "Move to left of tabs" : "Move to right of tabs" } +/** Reconcile a drop onto an empty-slot drop zone (a plain `useDroppable`, + * not a sortable). + * + * The `OptimisticSortingPlugin` requires both groups to have at least one + * `useSortable` item, so when a slot is empty its drop zone is a plain + * `Droppable` whose `id` equals the slot name. The plugin ignores it + * (the item snaps back). This function handles the commit: look up the + * control from `sourceGroup[sourceIndex]` and move it to `targetSlot` at + * index 0. Returns the original layout ref on no-op. */ +export function reconcileDropOnEmptySlot( + layout: TitlebarLayout, + sourceGroup: string | undefined, + sourceIndex: number, + targetSlot: "left" | "right", +): TitlebarLayout { + const from = (sourceGroup ?? "right") as "left" | "right" + if (from === targetSlot) return layout + const controlId = layout[from][sourceIndex] + if (!controlId) return layout + return moveToSlot(layout, controlId, targetSlot, 0) +} + /** Reconcile a drag-end event into a layout update. * * @dnd-kit's `OptimisticSortingPlugin` mutates `source.group` and diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 89900f805..80e3f76ea 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -36,7 +36,7 @@ import { tabHref, useTabs, type Tab } from "@/context/tabs" import type { PromptSession } from "@/context/prompt" import { normalizeSessionInfo } from "@/utils/session" import { channelBadgeText } from "./titlebar-channel" -import { type TitlebarControlId, type TitlebarLayout, mountPointId, isSessionScoped, defaultTitlebarLayout, reorderWithinSlot, moveToSlot, controlSlotLabel, reconcileDragEnd } from "./titlebar-layout" +import { type TitlebarControlId, type TitlebarLayout, mountPointId, isSessionScoped, defaultTitlebarLayout, reorderWithinSlot, moveToSlot, controlSlotLabel, reconcileDropOnEmptySlot, reconcileDragEnd } from "./titlebar-layout" import "./titlebar.css" const legacyTitlebarHeight = 40 @@ -465,6 +465,29 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl const source = event.operation.source if (!isSortable(source)) return const currentLayout = settings.general.titlebarLayout() + + // When the target is a plain droppable (not a sortable), + // the OptimisticSortingPlugin never ran — source.group and + // source.index still reflect the original position. Check + // whether the drop landed on an empty-slot drop zone and + // handle the cross-slot move manually. + const target = event.operation.target + if (target && !isSortable(target)) { + const targetId = target.id as string + if (targetId === "left" || targetId === "right") { + const updated = reconcileDropOnEmptySlot( + currentLayout, + source.initialGroup as string | undefined, + source.initialIndex, + targetId, + ) + if (updated !== currentLayout) { + settings.general.setTitlebarLayout(updated) + } + } + return + } + const updated = reconcileDragEnd( currentLayout, source.initialGroup as string | undefined, From 14c4a0f9714a268679fb3641cfa85f4b68f185cf Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Tue, 1 Sep 2026 22:55:19 -0400 Subject: [PATCH 09/12] fix(app): titlebar control spacing and sessions button size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TitlebarControlSlot gap-0 → gap-1 so controls aren't jammed together. SessionChatsDropdown trigger p-1.5 → size-9 (fixed 36px square) to match the other IconButtonV2 large buttons in the titlebar. --- packages/app/src/components/session/session-header.tsx | 2 +- packages/app/src/components/titlebar.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 4bdc58ea6..643dfb605 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -954,7 +954,7 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) ref={triggerRef} type="button" data-action="session-chats-toggle-flyout" - class="flex shrink-0 items-center justify-center rounded-sm border-none bg-transparent p-1.5 cursor-pointer text-v2-icon-icon-muted hover:text-v2-icon-icon-base hover:bg-v2-overlay-simple-overlay-hover transition-colors" + class="flex size-9 shrink-0 items-center justify-center rounded-sm border-none bg-transparent cursor-pointer text-v2-icon-icon-muted hover:text-v2-icon-icon-base hover:bg-v2-overlay-simple-overlay-hover transition-colors" aria-label="Sessions" onClick={() => setOpen(!open())} > diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 80e3f76ea..e699a24a5 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -808,7 +808,7 @@ function TitlebarControlSlot(props: { // edit-mode toggle. DragDropProvider is lifted to the V2 layout level // so cross-slot drag works; this component only renders the sorted items. return ( -
+
{(id, index) => ( Date: Tue, 1 Sep 2026 23:07:19 -0400 Subject: [PATCH 10/12] fix(app): portal new-session buttons to V2 per-control mount points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NewSessionStatus portaled Sessions and Status into #opencode-titlebar-right which only exists in the legacy titlebar. The V2 titlebar uses per-control mount points (#opencode-titlebar-sessions, #opencode-titlebar-status, etc.) so the portals silently landed nowhere — the buttons vanished on every new session / draft tab. Switch NewSessionPage to useTitlebarControlMount('sessions') and useTitlebarControlMount('status'), and split NewSessionStatus into two individual calls matching the SessionHeader pattern for active sessions. Also harden useTitlebarControlMount with a polling loop (up to 10 rAF frames) instead of a single retry, covering the race when the hook is called from a lazy-loaded route inside . --- packages/app/src/components/titlebar.tsx | 22 +++++++--- packages/app/src/pages/new-session.tsx | 7 ++-- .../pages/new-session/new-session-view.tsx | 40 ++++++++++++------- 3 files changed, 47 insertions(+), 22 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index e699a24a5..9f08e01a8 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -60,17 +60,29 @@ export function useTitlebarRightMount() { export function useTitlebarControlMount(id: TitlebarControlId) { const [mount, setMount] = createSignal(null) const elId = mountPointId(id) - // Live query: re-check the DOM every animation frame until the mount point - // is found, then stop. This replaces the old one-shot `onMount` + - // `getElementById` which went stale whenever the branch recreated - // the mount-point div (the root cause of the disappearing-buttons bug). + // Poll until the mount-point div appears. The titlebar renders the divs + // unconditionally, but when this hook is called from a lazy-loaded route + // inside , onMount may fire before the titlebar's DOM is + // committed. A short polling loop covers the race. onMount(() => { const found = document.getElementById(elId) if (found) { setMount(found) return } - const raf = requestAnimationFrame(() => setMount(document.getElementById(elId))) + let attempts = 0 + const poll = () => { + const el = document.getElementById(elId) + if (el) { + setMount(el) + return + } + if (++attempts < 10) { + const raf = requestAnimationFrame(poll) + onCleanup(() => cancelAnimationFrame(raf)) + } + } + const raf = requestAnimationFrame(poll) onCleanup(() => cancelAnimationFrame(raf)) }) return mount diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index 0f7a6fe95..1c0c60f6f 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -1,5 +1,5 @@ import { createPromptProjectController } from "@/components/prompt-project-selector" -import { useTitlebarRightMount } from "@/components/titlebar" +import { useTitlebarControlMount } from "@/components/titlebar" import { useSettings } from "@/context/settings" import { createEffect, createResource, onMount } from "solid-js" import { useLocation } from "@solidjs/router" @@ -13,7 +13,8 @@ import { useAmicodeCommands } from "@/pages/session/use-amicode-commands" /** The draft-only V2 session page. Submitting promotes the draft into a real session. */ export default function NewSessionPage() { const settings = useSettings() - const rightMount = useTitlebarRightMount() + const sessionsMount = useTitlebarControlMount("sessions") + const statusMount = useTitlebarControlMount("status") const workspace = createNewSessionWorkspaceController() const draft = createNewSessionDraftController({ worktree: workspace.selection.value, @@ -55,7 +56,7 @@ export default function NewSessionPage() { return (
{suspendUntilPromptReady()} - +
; visible: Accessor }) { +export function NewSessionStatus(props: { + sessionsMount: Accessor + statusMount: Accessor + visible: Accessor +}) { const language = useLanguage() return ( - - {(mount) => ( - -
- + <> + + {(mount) => ( + + - - - - -
-
- )} -
+ + + )} + + + {(mount) => ( + + + + + + + + )} + + ) } From fd2c8e7f4d3a28c84df2d2395a8923e168ce754b Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Wed, 2 Sep 2026 10:32:23 -0400 Subject: [PATCH 11/12] fix(app): match sessions button height to other titlebar controls (28px) --- packages/app/src/components/session/session-header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 643dfb605..37ffe4263 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -954,7 +954,7 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) ref={triggerRef} type="button" data-action="session-chats-toggle-flyout" - class="flex size-9 shrink-0 items-center justify-center rounded-sm border-none bg-transparent cursor-pointer text-v2-icon-icon-muted hover:text-v2-icon-icon-base hover:bg-v2-overlay-simple-overlay-hover transition-colors" + class="flex w-9 h-7 shrink-0 items-center justify-center rounded-sm border-none bg-transparent cursor-pointer text-v2-icon-icon-muted hover:text-v2-icon-icon-base hover:bg-v2-overlay-simple-overlay-hover transition-colors" aria-label="Sessions" onClick={() => setOpen(!open())} > From 7c5a6707b260d62da8d963cd9a99f1c9f54ef6b7 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Wed, 2 Sep 2026 12:07:16 -0400 Subject: [PATCH 12/12] fix(app): prevent titlebar buttons from disappearing on invalid DnD drops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two interacting bugs caused buttons to vanish when dropped on a tab or empty space during edit mode: 1. onDragEnd fell through to reconcileDragEnd when event.operation.target was null. The OptimisticSortingPlugin does NOT revert source.group / source.index on a non-canceled drop, so stale intermediate values from the plugin produced an unintended cross-slot move. 2. useTitlebarControlMount cached the mount-point element reference once on mount. When a cross-slot move destroyed the old div and the loop created a new one (same ID, different DOM node), the portal still targeted the detached element — rendering into the void. Fixes: - Guard onDragEnd: return early when target is null (void drop), letting the Solid adapter's restorePosition snap the button back. - Harden reconcileDragEnd: reject unrecognized group names and out-of- bounds initialIndex, returning the original layout unchanged. - Harden reorderWithinSlot: reject out-of-bounds fromIndex instead of splicing undefined into the array. - Make useTitlebarControlMount reactive: createEffect tracks titlebarLayout() and re-queries getElementById via queueMicrotask, so portals retarget when mount-point divs are recreated by cross-slot moves (both accidental and intentional via context menu). 7 new unit tests covering the edge cases. --- .../src/components/titlebar-layout.test.ts | 42 +++++++++++++++++++ .../app/src/components/titlebar-layout.ts | 25 +++++++---- packages/app/src/components/titlebar.tsx | 30 ++++++++++--- 3 files changed, 84 insertions(+), 13 deletions(-) diff --git a/packages/app/src/components/titlebar-layout.test.ts b/packages/app/src/components/titlebar-layout.test.ts index 119741a84..ebbcf8340 100644 --- a/packages/app/src/components/titlebar-layout.test.ts +++ b/packages/app/src/components/titlebar-layout.test.ts @@ -152,6 +152,28 @@ describe("reorder operations", () => { expect(result).toEqual(layout) }) + test("reorderWithinSlot returns original layout when fromIndex is out of bounds", () => { + const layout = defaultTitlebarLayout + const result = reorderWithinSlot(layout, "right", 99, 0) + expect(result).toBe(layout) + }) + + test("reorderWithinSlot returns original layout when fromIndex is negative", () => { + const layout = defaultTitlebarLayout + const result = reorderWithinSlot(layout, "right", -1, 0) + expect(result).toBe(layout) + }) + + test("reorderWithinSlot clamps toIndex to end of slot when out of bounds", () => { + const layout = { + left: [], + right: ["sessions", "status", "side-panel", "profile", "settings"], + } satisfies TitlebarLayout + const result = reorderWithinSlot(layout, "right", 0, 99) + // "sessions" moves to the end + expect(result.right).toEqual(["status", "side-panel", "profile", "settings", "sessions"]) + }) + test("moveToSlot transfers a control from right to left", () => { const layout = { left: [], @@ -329,6 +351,26 @@ describe("reconcileDragEnd", () => { const result = reconcileDragEnd(layout, undefined, 0, undefined, 2) expect(result.right).toEqual(["status", "side-panel", "sessions", "profile", "settings"]) }) + + test("returns original layout when destination group is not left or right", () => { + const result = reconcileDragEnd(layout, "right", 0, "tabs", 1) + expect(result).toBe(layout) + }) + + test("returns original layout when source group is not left or right", () => { + const result = reconcileDragEnd(layout, "tabs", 0, "right", 1) + expect(result).toBe(layout) + }) + + test("returns original layout when initialIndex is out of bounds", () => { + const result = reconcileDragEnd(layout, "right", 99, "right", 1) + expect(result).toBe(layout) + }) + + test("returns original layout when initialIndex is negative", () => { + const result = reconcileDragEnd(layout, "right", -1, "right", 1) + expect(result).toBe(layout) + }) }) describe("reconcileDropOnEmptySlot", () => { diff --git a/packages/app/src/components/titlebar-layout.ts b/packages/app/src/components/titlebar-layout.ts index d8565d246..266a022fd 100644 --- a/packages/app/src/components/titlebar-layout.ts +++ b/packages/app/src/components/titlebar-layout.ts @@ -72,7 +72,9 @@ export function reorderWithinSlot( toIndex: number, ): TitlebarLayout { if (fromIndex === toIndex) return layout - const items = [...layout[slot]] + const source = layout[slot] + if (fromIndex < 0 || fromIndex >= source.length) return layout + const items = [...source] const [moved] = items.splice(fromIndex, 1) items.splice(toIndex, 0, moved!) return { ...layout, [slot]: items } @@ -120,6 +122,8 @@ export function reconcileDropOnEmptySlot( return moveToSlot(layout, controlId, targetSlot, 0) } +const VALID_SLOTS: ReadonlySet = new Set(["left", "right"]) + /** Reconcile a drag-end event into a layout update. * * @dnd-kit's `OptimisticSortingPlugin` mutates `source.group` and @@ -131,7 +135,10 @@ export function reconcileDropOnEmptySlot( * - Same group, same index → no-op (returns the original layout ref) * * `initialGroup` / `group` may be `undefined` — treat as `"right"` (the - * default slot). */ + * default slot). Returns the original layout unchanged when either group + * is not a recognized slot name or when `initialIndex` is out of bounds — + * this prevents the OptimisticSortingPlugin's stale intermediate values + * from producing a corrupt layout on void drops. */ export function reconcileDragEnd( layout: TitlebarLayout, initialGroup: string | undefined, @@ -139,10 +146,14 @@ export function reconcileDragEnd( group: string | undefined, index: number, ): TitlebarLayout { - const from = (initialGroup ?? "right") as "left" | "right" - const to = (group ?? "right") as "left" | "right" - if (from === to) return reorderWithinSlot(layout, from, initialIndex, index) - const controlId = layout[from][initialIndex] + const from = initialGroup ?? "right" + const to = group ?? "right" + if (!VALID_SLOTS.has(from) || !VALID_SLOTS.has(to)) return layout + const fromSlot = from as "left" | "right" + const toSlot = to as "left" | "right" + if (initialIndex < 0 || initialIndex >= layout[fromSlot].length) return layout + if (fromSlot === toSlot) return reorderWithinSlot(layout, fromSlot, initialIndex, index) + const controlId = layout[fromSlot][initialIndex] if (!controlId) return layout - return moveToSlot(layout, controlId, to, index) + return moveToSlot(layout, controlId, toSlot, index) } diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 9f08e01a8..29170d901 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -58,12 +58,22 @@ export function useTitlebarRightMount() { } export function useTitlebarControlMount(id: TitlebarControlId) { + const settings = useSettings() const [mount, setMount] = createSignal(null) const elId = mountPointId(id) - // Poll until the mount-point div appears. The titlebar renders the divs - // unconditionally, but when this hook is called from a lazy-loaded route - // inside , onMount may fire before the titlebar's DOM is - // committed. A short polling loop covers the race. + + // Re-query the DOM whenever the titlebar layout changes. Cross-slot + // moves destroy the old mount-point div and the loop creates a new + // one with the same ID — the portal needs to retarget. queueMicrotask + // defers until after Solid's synchronous render pass has committed the + // new div. + createEffect(() => { + settings.general.titlebarLayout() + queueMicrotask(() => setMount(document.getElementById(elId))) + }) + + // Belt-and-suspenders: if the first microtask didn't find the element + // (lazy route behind ), poll a few rAF frames. onMount(() => { const found = document.getElementById(elId) if (found) { @@ -85,6 +95,7 @@ export function useTitlebarControlMount(id: TitlebarControlId) { const raf = requestAnimationFrame(poll) onCleanup(() => cancelAnimationFrame(raf)) }) + return mount } @@ -476,6 +487,14 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl if (!editMode() || event.canceled) return const source = event.operation.source if (!isSortable(source)) return + + // No valid target → the button was dropped in the void + // (on a tab, outside the window, etc.). Don't change + // anything — the Solid adapter's restorePosition already + // put the DOM element back, so the button snaps home. + const target = event.operation.target + if (!target) return + const currentLayout = settings.general.titlebarLayout() // When the target is a plain droppable (not a sortable), @@ -483,8 +502,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl // source.index still reflect the original position. Check // whether the drop landed on an empty-slot drop zone and // handle the cross-slot move manually. - const target = event.operation.target - if (target && !isSortable(target)) { + if (!isSortable(target)) { const targetId = target.id as string if (targetId === "left" || targetId === "right") { const updated = reconcileDropOnEmptySlot(