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
Binary file added .github/pr-assets/model-drag-reorder.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 23 additions & 2 deletions apps/desktop/src/components/settings/ModelSelectionPanes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@ import {
matchPresetIndex,
} from "../../lib/model-limit-presets";
import { Button, Field, Input, Tooltip, TooltipButton, cx } from "../ui";
import { IconClose, IconHelp, IconPlus, IconRefresh, IconSearch } from "../icons";
import { IconClose, IconGripVertical, IconHelp, IconPlus, IconRefresh, IconSearch } from "../icons";
import { SettingsMenuSelect } from "./SettingsMenuSelect";
import { filterChosenModels, hidesAddedBinding } from "./model-chosen-filter";
import { describeModelsFetchError } from "./model-fetch-error";
import type { ProviderModelsState } from "./useProviderModels";
import { useModelReorder } from "./useModelReorder";

/** One row of the model list: what the service returned, plus its binding. */
export type ModelRow = {
Expand Down Expand Up @@ -256,6 +257,7 @@ export function ModelSelectionPanes({
() => filterChosenModels(models, chosenQuery, rows),
[chosenQuery, models, rows],
);
const reorder = useModelReorder(visibleChosen, setModels, busy);

/** A discovered row arrives enriched; a hand-typed id gets generic limits. */
const bindingForRow = (row: ModelRow): ModelBinding =>
Expand Down Expand Up @@ -511,8 +513,27 @@ export function ModelSelectionPanes({
const expanded = expandedModelId === binding.id;
const advancedId = `model-advanced-${binding.id}`;
return (
<li className="provider-chosen-row" key={binding.id}>
<li
className={cx(
"provider-chosen-row",
reorder.draggingId === binding.id && "is-dragging",
)}
key={binding.id}
data-drop-placement={
reorder.dropTarget?.id === binding.id ? reorder.dropTarget.placement : undefined
}
{...reorder.rowEvents(binding.id)}
>
<div className="provider-chosen-row-head">
<button
type="button"
className="provider-chosen-reorder"
aria-label={t("settings.reorderModel", { name: binding.id })}
title={t("settings.reorderModel", { name: binding.id })}
{...reorder.handleEvents(binding.id)}
>
<IconGripVertical size={14} aria-hidden />
</button>
<span className="provider-chosen-row-id font-mono selectable">
{binding.id}
</span>
Expand Down
58 changes: 58 additions & 0 deletions apps/desktop/src/components/settings/model-reorder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/** Drag payload so a drop can recover the source if React state lags. */
export const MODEL_REORDER_MIME = "application/x-pi-desktop-model";

export type ReorderPlacement = "before" | "after";

export type DropTarget = { id: string; placement: ReorderPlacement };

/** Upper half including the midpoint inserts before; below the midpoint inserts after. */
export function dropPlacement(
clientY: number,
targetTop: number,
targetHeight: number,
): ReorderPlacement {
return clientY > targetTop + targetHeight / 2 ? "after" : "before";
}

/** Adjacent *visible* row for ArrowUp / ArrowDown; hidden filter matches stay put. */
export function visibleNeighborMove<T extends { id: string }>(
visible: T[],
id: string,
direction: "up" | "down",
): { targetId: string; placement: ReorderPlacement } | null {
const index = visible.findIndex((model) => model.id === id);
if (index < 0) return null;
const target = visible[index + (direction === "down" ? 1 : -1)];
if (!target) return null;
return {
targetId: target.id,
placement: direction === "down" ? "after" : "before",
};
}

export function sameDropTarget(
current: DropTarget | null,
next: DropTarget | null,
): boolean {
return current?.id === next?.id && current?.placement === next?.placement;
}

/** Move one binding without rebuilding it or dropping models hidden by a filter. */
export function reorderModel<T extends { id: string }>(
models: T[],
sourceId: string,
targetId: string,
placement: ReorderPlacement,
): T[] {
const sourceIndex = models.findIndex((model) => model.id === sourceId);
const targetIndex = models.findIndex((model) => model.id === targetId);
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return models;

const insertionIndex =
targetIndex + (placement === "after" ? 1 : 0) - (sourceIndex < targetIndex ? 1 : 0);
if (insertionIndex === sourceIndex) return models;
const next = [...models];
const [binding] = next.splice(sourceIndex, 1);
next.splice(insertionIndex, 0, binding);
return next;
}
103 changes: 103 additions & 0 deletions apps/desktop/src/components/settings/useModelReorder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { useEffect, useState, type ButtonHTMLAttributes, type HTMLAttributes } from "react";
import type { ModelBinding } from "@pi-desktop/shared";
import {
MODEL_REORDER_MIME,
dropPlacement,
reorderModel,
sameDropTarget,
visibleNeighborMove,
type DropTarget,
} from "./model-reorder";

/** Dragging previews a destination; only dropping or an arrow key edits the draft. */
export function useModelReorder(
visibleModels: ModelBinding[],
setModels: (update: (current: ModelBinding[]) => ModelBinding[]) => void,
busy: boolean,
) {
const [draggingId, setDraggingId] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const disabled = busy || visibleModels.length < 2;

const clearDrag = () => {
setDraggingId(null);
setDropTarget(null);
};

useEffect(() => {
if (disabled || !visibleModels.some((model) => model.id === draggingId)) {
setDraggingId(null);
setDropTarget(null);
}
}, [disabled, draggingId, visibleModels]);

const destination = (id: string, clientY: number, element: HTMLElement): DropTarget => {
const rect = element.getBoundingClientRect();
return { id, placement: dropPlacement(clientY, rect.top, rect.height) };
};

const previewDrop = (id: string, clientY: number, element: HTMLElement) => {
const next = id === draggingId ? null : destination(id, clientY, element);
setDropTarget((current) => (sameDropTarget(current, next) ? current : next));
};

const rowEvents = (id: string): HTMLAttributes<HTMLLIElement> => ({
onDragEnter(event) {
if (disabled || !draggingId) return;
event.preventDefault();
event.stopPropagation();
},
onDragOver(event) {
if (disabled || !draggingId) return;
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "move";
previewDrop(id, event.clientY, event.currentTarget);
},
onDragLeave(event) {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setDropTarget((current) => (current?.id === id ? null : current));
}
},
onDrop(event) {
const sourceId = draggingId || event.dataTransfer.getData(MODEL_REORDER_MIME);
if (disabled || !sourceId) return;
event.preventDefault();
event.stopPropagation();
const target = destination(id, event.clientY, event.currentTarget);
setModels((current) => reorderModel(current, sourceId, id, target.placement));
clearDrag();
},
});

const handleEvents = (id: string): ButtonHTMLAttributes<HTMLButtonElement> => ({
disabled,
draggable: !disabled,
onDragStart(event) {
if (disabled) {
event.preventDefault();
return;
}
event.stopPropagation();
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(MODEL_REORDER_MIME, id);
setDraggingId(id);
setDropTarget(null);
},
onDragEnd: clearDrag,
onKeyDown(event) {
if (disabled || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
event.preventDefault();
event.stopPropagation();
const move = visibleNeighborMove(
visibleModels,
id,
event.key === "ArrowDown" ? "down" : "up",
);
if (!move) return;
setModels((current) => reorderModel(current, id, move.targetId, move.placement));
},
});

return { draggingId, dropTarget, rowEvents, handleEvents };
}
54 changes: 54 additions & 0 deletions apps/desktop/src/styles/model-config.css
Original file line number Diff line number Diff line change
Expand Up @@ -845,10 +845,63 @@
}

.provider-chosen-row {
position: relative;
border-radius: var(--radius-3xs);
background: var(--ds-tile);
}

.provider-chosen-row.is-dragging {
opacity: 0.5;
}

.provider-chosen-row[data-drop-placement]::after {
position: absolute;
right: 0;
left: 0;
height: 2px;
background: var(--ds-accent);
pointer-events: none;
content: "";
}

.provider-chosen-row[data-drop-placement="before"]::after {
top: -2px;
}

.provider-chosen-row[data-drop-placement="after"]::after {
bottom: -2px;
}

.provider-chosen-reorder {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: var(--radius-2xs);
background: transparent;
color: var(--ds-text-muted);
cursor: grab;
user-select: none;
}

.provider-chosen-reorder:active {
cursor: grabbing;
}

.provider-chosen-reorder:hover:not(:disabled) {
background: var(--ds-bg-hover);
color: var(--ds-text-primary);
}

.provider-chosen-reorder:disabled {
opacity: 0.35;
cursor: default;
}

.provider-chosen-row-head {
display: flex;
align-items: center;
Expand Down Expand Up @@ -908,6 +961,7 @@
}

.provider-chosen-advanced-toggle:focus-visible,
.provider-chosen-reorder:focus-visible,
.provider-chosen-remove:focus-visible {
outline: 2px solid var(--ds-accent-soft);
outline-offset: 1px;
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/test/composer-models.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ test("configured models remain selectable when discovery is unavailable", () =>
assert.equal(models[0].displayName, "my-model-v2");
});

test("Composer preserves configured order even when discovery returns another order", () => {
const configured = ["z-custom", "gpt-6-astra", "claude-opus-4-6"];
const models = composerModelsForProvider(
{ id: "custom", models: configured.map(binding) },
[model("claude-opus-4-6"), model("gpt-6-astra")],
);
assert.deepEqual(models.map(({ modelId }) => modelId), configured);
});

test("legacy providers fall back to their default model binding", () => {
const models = composerModelsForProvider(
{ id: "legacy", models: [], defaultModelId: "legacy-model" },
Expand Down
Loading
Loading