Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions llm-host/src/LlmConfigTray.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(".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();
});
});
25 changes: 18 additions & 7 deletions llm-host/src/LlmConfigTray.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -56,9 +56,12 @@ export function LlmConfigTray(element: HTMLElement) {
const [open, setOpen] = createSignal(false);
const [scope, setScope] = createSignal<Scope | undefined>();
const [payload, setPayload] = createSignal<any>({});
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;
Expand Down Expand Up @@ -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<Scope> | undefined, p: any) {
Expand Down Expand Up @@ -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()}
Expand Down
31 changes: 31 additions & 0 deletions threepane/src/account/ensureThreepaneConfig.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
9 changes: 7 additions & 2 deletions threepane/src/account/ensureThreepaneConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions threepane/src/components/SlotView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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).
*
Expand Down
4 changes: 2 additions & 2 deletions threepane/src/components/Tray.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,7 +13,7 @@ export function Tray(props: { slots: Accessor<ToolSlot[]> }) {
return (
<Show when={props.slots().length}>
<div class="frame-tray">
<For each={props.slots()}>
<For each={uniqueSlots(props.slots())}>
{(slot) => (
<div class="frame-tray__item" data-tray-id={slotId(slot)}>
<SlotView slot={slot} />
Expand Down
Loading