diff --git a/llm-host/src/LlmConfigTray.test.tsx b/llm-host/src/LlmConfigTray.test.tsx new file mode 100644 index 00000000..c8d702c1 --- /dev/null +++ b/llm-host/src/LlmConfigTray.test.tsx @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { LlmConfigTray } from "./LlmConfigTray"; + +vi.mock("@chee/patchwork-llm", () => ({ + ensureConfig: vi.fn(async () => {}), + dom: vi.fn(() => document.createElement("div")), + readScopedConfig: vi.fn(), + readConfig: vi.fn(), + writeScopeOverride: vi.fn(), + writeConfig: vi.fn(), +})); + +afterEach(() => { + document.body.replaceChildren(); + vi.restoreAllMocks(); +}); + +describe("model picker position", () => { + it.each([ + [{ top: 50, bottom: 78, right: 300 }, "86px", "none"], + [{ top: 650, bottom: 678, right: 300 }, "642px", "translateY(-100%)"], + ])("keeps the picker in the viewport for button at %o", (rect, top, transform) => { + vi.spyOn(window, "innerHeight", "get").mockReturnValue(768); + vi.spyOn(window, "innerWidth", "get").mockReturnValue(1024); + const host = document.createElement("div"); + document.body.append(host); + const dispose = LlmConfigTray(host); + const button = host.querySelector("button")!; + vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect as DOMRect); + + button.click(); + const popover = document.querySelector(".llm-config-tray-popover")!; + expect(popover.style.top).toBe(top); + expect(popover.style.transform).toBe(transform); + expect(parseFloat(popover.style.maxHeight)).toBeCloseTo(614.4); + dispose(); + }); +}); diff --git a/llm-host/src/LlmConfigTray.tsx b/llm-host/src/LlmConfigTray.tsx index 1f11fe3e..5eeecb41 100644 --- a/llm-host/src/LlmConfigTray.tsx +++ b/llm-host/src/LlmConfigTray.tsx @@ -40,7 +40,7 @@ export function LlmConfigTray(element: HTMLElement) { background: var(--studio-chrome-offset-10, var(--studio-fill-offset-10, #f2f2f2)); } .llm-config-tray-popover { - position: fixed; z-index: 2147483000; + position: fixed; z-index: 2147483000; box-sizing: border-box; max-width: min(92vw, 520px); max-height: 80vh; overflow: auto; background: var(--studio-fill, white); color: var(--studio-line, black); border: 1px solid var(--studio-fill-offset-20, #d4d4d4); @@ -56,9 +56,12 @@ export function LlmConfigTray(element: HTMLElement) { const [open, setOpen] = createSignal(false); const [scope, setScope] = createSignal(); const [payload, setPayload] = createSignal({}); - const [pos, setPos] = createSignal<{ left: number; top: number } | null>( - null - ); + const [pos, setPos] = createSignal<{ + left: number; + top: number; + maxHeight: number; + above: boolean; + } | null>(null); let buttonEl!: HTMLButtonElement; let popoverEl: HTMLDivElement | undefined; @@ -102,8 +105,15 @@ export function LlmConfigTray(element: HTMLElement) { function positionPopover() { const r = buttonEl.getBoundingClientRect(); - // Prefer opening above the tray (tray sits at the bottom), aligned right. - setPos({ left: Math.max(8, r.right - 520), top: Math.max(8, r.top - 8) }); + const aboveSpace = Math.max(0, r.top - 16); + const belowSpace = Math.max(0, window.innerHeight - r.bottom - 16); + const above = aboveSpace >= Math.min(window.innerHeight * 0.8, belowSpace); + setPos({ + left: Math.max(8, r.right - Math.min(520, window.innerWidth * 0.92)), + top: above ? r.top - 8 : r.bottom + 8, + maxHeight: Math.min(window.innerHeight * 0.8, above ? aboveSpace : belowSpace), + above, + }); } function openPicker(sc: Partial | undefined, p: any) { @@ -178,7 +188,8 @@ export function LlmConfigTray(element: HTMLElement) { style={{ left: `${pos()?.left ?? 0}px`, top: `${pos()?.top ?? 0}px`, - transform: "translateY(-100%)", + "max-height": `${pos()?.maxHeight ?? 0}px`, + transform: pos()?.above ? "translateY(-100%)" : "none", visibility: pos() ? "visible" : "hidden", }} onClick={(event) => event.stopPropagation()} diff --git a/threepane/src/account/ensureThreepaneConfig.test.ts b/threepane/src/account/ensureThreepaneConfig.test.ts new file mode 100644 index 00000000..864ffd92 --- /dev/null +++ b/threepane/src/account/ensureThreepaneConfig.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; +import { ensureThreepaneConfig } from "./ensureThreepaneConfig"; +import { uniqueSlots } from "../components/SlotView"; + +vi.mock("@inkandswitch/patchwork-plugins", () => ({ + createDocOfDatatype2: vi.fn(), + getRegistry: vi.fn(), +})); + +describe("tray slots", () => { + it("keeps the first slot for each tool, including tuple slots", () => { + const slots = ["theme-tray", "llm-config-tray", ["llm-config-tray", "doc"], "other", "llm-config-tray"] as any; + expect(uniqueSlots(slots)).toEqual(["theme-tray", "llm-config-tray", "other"]); + }); + + it("cleans up saved duplicates and does not add another model button on reload", async () => { + const config = { + sidebar: { widgets: [] }, + doctitle: { tools: ["doc-openwith"] }, + tray: ["theme-tray", "llm-config-tray", "llm-config-tray"], + }; + const configHandle = { change: vi.fn((fn: (doc: typeof config) => void) => fn(config)) }; + const accountHandle = { doc: () => ({ tools: { threepane: "config" } }) }; + const repo = { find: vi.fn(async () => configHandle) }; + + await ensureThreepaneConfig(accountHandle as any, repo as any); + await ensureThreepaneConfig(accountHandle as any, repo as any); + + expect(config.tray).toEqual(["theme-tray", "llm-config-tray"]); + }); +}); diff --git a/threepane/src/account/ensureThreepaneConfig.ts b/threepane/src/account/ensureThreepaneConfig.ts index c6f73c72..30eec60f 100644 --- a/threepane/src/account/ensureThreepaneConfig.ts +++ b/threepane/src/account/ensureThreepaneConfig.ts @@ -3,7 +3,7 @@ import { createDocOfDatatype2 } from "@inkandswitch/patchwork-plugins"; import type { AccountDoc, ThreepaneConfigDoc, ToolRef, ToolSlot } from "../types"; import { DEFAULT_DOCTITLE_TOOLS, DEFAULT_TRAY_TOOLS } from "../datatypes"; import { loadDatatypeWhenReady } from "./loadDatatypeWhenReady"; -import { slotId } from "../components/SlotView"; +import { slotId, uniqueSlots } from "../components/SlotView"; // Title + spacer are intrinsic to the frame's top bar, never configured tools. const INTRINSIC_DOCTITLE_TOOLS = new Set(["document-title", "spacer"]); @@ -52,9 +52,14 @@ export async function ensureThreepaneConfig( } else { // Append default tray tools the saved tray is missing, like // llm-config-tray. slotId compares across the string and tuple forms. + const unique = uniqueSlots(doc.tray); + if (unique.length !== doc.tray.length) doc.tray = unique; const present = new Set(doc.tray.map((s) => slotId(s))); for (const id of DEFAULT_TRAY_TOOLS) { - if (!present.has(id)) doc.tray.push(id); + if (!present.has(id)) { + doc.tray.push(id); + present.add(id); + } } } // An account whose doctitle lane ended up empty (nothing to migrate from diff --git a/threepane/src/components/SlotView.tsx b/threepane/src/components/SlotView.tsx index 8376e2aa..0efe9654 100644 --- a/threepane/src/components/SlotView.tsx +++ b/threepane/src/components/SlotView.tsx @@ -11,6 +11,16 @@ import type { ToolSlot } from "../types"; export const slotId = (slot: ToolSlot): string => Array.isArray(slot) ? String(slot[0]) : String(slot); +export function uniqueSlots(slots: ToolSlot[]): ToolSlot[] { + const seen = new Set(); + return slots.filter((slot) => { + const id = slotId(slot); + if (seen.has(id)) return false; + seen.add(id); + return true; + }); +} + /** * Render one configured tool-lane slot (doctitle / sidebar). * diff --git a/threepane/src/components/Tray.tsx b/threepane/src/components/Tray.tsx index 7b70b9b5..41e1630a 100644 --- a/threepane/src/components/Tray.tsx +++ b/threepane/src/components/Tray.tsx @@ -1,6 +1,6 @@ import { For, Show, type Accessor } from "solid-js"; import type { ToolSlot } from "../types"; -import { SlotView, slotId } from "./SlotView"; +import { SlotView, slotId, uniqueSlots } from "./SlotView"; /** * A horizontal row of the system-tray tools configured in the threepane config @@ -13,7 +13,7 @@ export function Tray(props: { slots: Accessor }) { return (
- + {(slot) => (