diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index dae97e3c9..37ffe4263 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}> + + + + } + > + + } + /> + + + + )} + + + ) } @@ -891,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 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())} > 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..ebbcf8340 --- /dev/null +++ b/packages/app/src/components/titlebar-layout.test.ts @@ -0,0 +1,414 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { createRoot, createSignal } from "solid-js" +import { + type TitlebarLayout, + type TitlebarControlId, + TITLEBAR_CONTROL_IDS, + defaultTitlebarLayout, + mountPointId, + isSessionScoped, + validateTitlebarLayout, + createEditModeState, + reorderWithinSlot, + moveToSlot, + createMountPointTracker, + controlSlotLabel, + reconcileDragEnd, + reconcileDropOnEmptySlot, +} 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) + }) + + 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) + }) +}) + +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) + }) +}) + +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("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: [], + 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"]) + }) +}) + +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() + }) +}) + +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") + }) +}) + +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"]) + }) + + 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", () => { + 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 new file mode 100644 index 000000000..266a022fd --- /dev/null +++ b/packages/app/src/components/titlebar-layout.ts @@ -0,0 +1,159 @@ +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[] } +} + +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) +} + +/** 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 { + active: () => _active, + enter: () => { + _active = true + }, + exit: () => { + _active = false + }, + reset: (write: (layout: TitlebarLayout) => void) => { + write(defaultTitlebarLayout) + _active = false + }, + } +} + +export function reorderWithinSlot( + layout: TitlebarLayout, + slot: "left" | "right", + fromIndex: number, + toIndex: number, +): TitlebarLayout { + if (fromIndex === toIndex) return layout + 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 } +} + +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 } +} + +/** 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" +} + +/** 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) +} + +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 + * `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). 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, + initialIndex: number, + group: string | undefined, + index: number, +): TitlebarLayout { + 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, toSlot, index) +} diff --git a/packages/app/src/components/titlebar.css b/packages/app/src/components/titlebar.css index 43104c153..af3f6298a 100644 --- a/packages/app/src/components/titlebar.css +++ b/packages/app/src/components/titlebar.css @@ -75,3 +75,86 @@ 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 { + position: relative; + border: 1px dashed var(--v2-border-default, rgba(255, 255, 255, 0.12)); + border-radius: 6px; + 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 { + 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; + 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)); +} + +/* 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 4cfb2494c..29170d901 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -1,6 +1,11 @@ -import { createEffect, createMemo, createResource, createSignal, 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 { 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" +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,6 +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, reconcileDropOnEmptySlot, reconcileDragEnd } from "./titlebar-layout" import "./titlebar.css" const legacyTitlebarHeight = 40 @@ -51,6 +57,48 @@ export function useTitlebarRightMount() { return mount } +export function useTitlebarControlMount(id: TitlebarControlId) { + const settings = useSettings() + const [mount, setMount] = createSignal(null) + const elId = mountPointId(id) + + // 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) { + setMount(found) + return + } + 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 +} + export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) { const layout = useLayout() const platform = usePlatform() @@ -65,6 +113,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") @@ -174,6 +266,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl return (
+ [ + ...defaults, + Feedback.configure({ dropAnimation: null }), + ]} + onDragEnd={(event) => { + 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), + // 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. + if (!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, + 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 Panel) — see TitlebarV2Right. */} @@ -424,7 +577,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
- + setEditMode(false)} /> +
) }} @@ -578,7 +732,17 @@ type TitlebarV2RightState = { update: TitlebarUpdatePillState } -function TitlebarV2Right(props: { state: TitlebarV2RightState }) { +function TitlebarControlSlot(props: { + controls: TitlebarControlId[] + slot: "left" | "right" + group: string + state: TitlebarV2RightState + editMode?: boolean + onExitEditMode?: () => void + onReorder?: (layout: TitlebarLayout) => void + layout?: TitlebarLayout + showCheckmark?: boolean +}) { const language = useLanguage() const command = useCommand() const dialog = useDialog() @@ -594,45 +758,215 @@ function TitlebarV2Right(props: { state: TitlebarV2RightState }) { ) }) } + + 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")} + /> + + + + +
+ + + ) + + // 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. DragDropProvider is lifted to the V2 layout level + // so cross-slot drag works; this component only renders the sorted items. return ( -
- - +
+ + {(id, index) => ( + showControlContextMenu(e, id)} + > + {renderControl(id)} + + )} + + + } + onClick={() => props.onExitEditMode?.()} + aria-label="Done customizing" + /> - {/* 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")} - /> - -
) } +function SortableControlItem(props: { + id: string + index: () => number + group?: string + editMode?: boolean + onContextMenu?: (e: MouseEvent) => void + children: any +}) { + const sortable = useSortable({ + get id() { + return props.id + }, + get index() { + return props.index() + }, + get group() { + return props.group + }, + get disabled() { + return !props.editMode + }, + }) + return ( +
{ + if (props.editMode) props.onContextMenu?.(e) + }} + classList={{ + "titlebar-control-wrapper": true, + "titlebar-control-edit": !!props.editMode, + "titlebar-control-dragging": !!props.editMode && sortable.isDragSource(), + }} + > + {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 + /> + ) +} + +function TitlebarV2Left(props: { state: TitlebarV2RightState; editMode: boolean }) { + const settings = useSettings() + const layout = createMemo(() => settings.general.titlebarLayout()) + const controls = createMemo(() => layout().left) + return ( + 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 ( + +
+ + ) +} + function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) { return (
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), 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) => ( + + + + + + + + )} + + ) }