diff --git a/.github/pr-assets/model-drag-reorder.png b/.github/pr-assets/model-drag-reorder.png
new file mode 100644
index 0000000000..f529849b3a
Binary files /dev/null and b/.github/pr-assets/model-drag-reorder.png differ
diff --git a/apps/desktop/src/components/settings/ModelSelectionPanes.tsx b/apps/desktop/src/components/settings/ModelSelectionPanes.tsx
index 5a9effaeef..b98e22092d 100644
--- a/apps/desktop/src/components/settings/ModelSelectionPanes.tsx
+++ b/apps/desktop/src/components/settings/ModelSelectionPanes.tsx
@@ -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 = {
@@ -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 =>
@@ -511,8 +513,27 @@ export function ModelSelectionPanes({
const expanded = expandedModelId === binding.id;
const advancedId = `model-advanced-${binding.id}`;
return (
-
+
{binding.id}
diff --git a/apps/desktop/src/components/settings/model-reorder.ts b/apps/desktop/src/components/settings/model-reorder.ts
new file mode 100644
index 0000000000..8ae878e881
--- /dev/null
+++ b/apps/desktop/src/components/settings/model-reorder.ts
@@ -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(
+ 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(
+ 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;
+}
diff --git a/apps/desktop/src/components/settings/useModelReorder.ts b/apps/desktop/src/components/settings/useModelReorder.ts
new file mode 100644
index 0000000000..047d267afb
--- /dev/null
+++ b/apps/desktop/src/components/settings/useModelReorder.ts
@@ -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(null);
+ const [dropTarget, setDropTarget] = useState(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 => ({
+ 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 => ({
+ 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 };
+}
diff --git a/apps/desktop/src/styles/model-config.css b/apps/desktop/src/styles/model-config.css
index fd23958b70..40db139b1f 100644
--- a/apps/desktop/src/styles/model-config.css
+++ b/apps/desktop/src/styles/model-config.css
@@ -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;
@@ -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;
diff --git a/apps/desktop/test/composer-models.test.mjs b/apps/desktop/test/composer-models.test.mjs
index 73cd4524e7..5055afbf41 100644
--- a/apps/desktop/test/composer-models.test.mjs
+++ b/apps/desktop/test/composer-models.test.mjs
@@ -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" },
diff --git a/apps/desktop/test/model-reorder.test.mjs b/apps/desktop/test/model-reorder.test.mjs
new file mode 100644
index 0000000000..baa14975b9
--- /dev/null
+++ b/apps/desktop/test/model-reorder.test.mjs
@@ -0,0 +1,106 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ dropPlacement,
+ reorderModel,
+ sameDropTarget,
+ visibleNeighborMove,
+} from "../src/components/settings/model-reorder.ts";
+
+const ids = (models) => models.map((model) => model.id);
+const models = [
+ { id: "first", alias: "Fast", contextWindow: 128000, thinkingLevels: ["high"] },
+ { id: "hidden", supportsImages: true },
+ { id: "middle", maxTokens: 32000 },
+ { id: "last", defaultThinkingLevel: "high", apiStyle: "responses" },
+];
+
+test("moves the first model to the end and the last to the beginning", () => {
+ assert.deepEqual(ids(reorderModel(models, "first", "last", "after")), [
+ "hidden", "middle", "last", "first",
+ ]);
+ assert.deepEqual(ids(reorderModel(models, "last", "first", "before")), [
+ "last", "first", "hidden", "middle",
+ ]);
+});
+
+test("inserts on either side of a target in both drag directions", () => {
+ assert.deepEqual(ids(reorderModel(models, "first", "middle", "before")), [
+ "hidden", "first", "middle", "last",
+ ]);
+ assert.deepEqual(ids(reorderModel(models, "last", "hidden", "after")), [
+ "first", "hidden", "last", "middle",
+ ]);
+});
+
+test("a filtered drag retains hidden models and every binding's overrides", () => {
+ const next = reorderModel(models, "last", "first", "before");
+ assert.deepEqual(ids(next), ["last", "first", "hidden", "middle"]);
+ for (const binding of models) {
+ assert.equal(next.find((model) => model.id === binding.id), binding);
+ }
+ assert.deepEqual(ids(models), ["first", "hidden", "middle", "last"]);
+});
+
+test("same-place drops and stale or missing targets leave the draft unchanged", () => {
+ for (const [source, target, side] of [
+ ["first", "first", "after"],
+ ["first", "hidden", "before"],
+ ["hidden", "first", "after"],
+ ["removed", "first", "before"],
+ ["first", "removed", "after"],
+ ]) {
+ assert.equal(reorderModel(models, source, target, side), models);
+ }
+ assert.deepEqual(reorderModel([], "first", "last", "after"), []);
+});
+
+test("drop placement splits a row at its vertical midpoint", () => {
+ assert.equal(dropPlacement(10, 0, 40), "before");
+ assert.equal(dropPlacement(20, 0, 40), "before");
+ assert.equal(dropPlacement(21, 0, 40), "after");
+});
+
+test("arrow keys move past the neighboring visible row and no-op at the ends", () => {
+ const visible = [{ id: "shown-a" }, { id: "shown-b" }, { id: "shown-c" }];
+ assert.deepEqual(visibleNeighborMove(visible, "shown-a", "down"), {
+ targetId: "shown-b",
+ placement: "after",
+ });
+ assert.deepEqual(visibleNeighborMove(visible, "shown-c", "up"), {
+ targetId: "shown-b",
+ placement: "before",
+ });
+ assert.equal(visibleNeighborMove(visible, "shown-a", "up"), null);
+ assert.equal(visibleNeighborMove(visible, "shown-c", "down"), null);
+ assert.equal(visibleNeighborMove(visible, "missing", "down"), null);
+});
+
+test("a filtered drag before the first visible row keeps hidden bindings in place", () => {
+ const all = [
+ { id: "shown-a" },
+ { id: "hidden-a" },
+ { id: "shown-b" },
+ { id: "hidden-b" },
+ { id: "shown-c" },
+ ];
+ const visible = all.filter((model) => model.id.startsWith("shown-"));
+ const move = visibleNeighborMove(visible, "shown-c", "up");
+ assert.deepEqual(move, { targetId: "shown-b", placement: "before" });
+ assert.deepEqual(
+ ids(reorderModel(all, "shown-c", "shown-a", "before")),
+ ["shown-c", "shown-a", "hidden-a", "shown-b", "hidden-b"],
+ );
+ assert.deepEqual(
+ ids(reorderModel(all, "shown-a", move.targetId, move.placement)),
+ ["hidden-a", "shown-a", "shown-b", "hidden-b", "shown-c"],
+ );
+});
+
+test("drop-target identity ignores equivalent previews", () => {
+ const target = { id: "shown-a", placement: "before" };
+ assert.equal(sameDropTarget(target, { id: "shown-a", placement: "before" }), true);
+ assert.equal(sameDropTarget(target, { id: "shown-a", placement: "after" }), false);
+ assert.equal(sameDropTarget(null, null), true);
+ assert.equal(sameDropTarget(target, null), false);
+});
diff --git a/apps/desktop/test/provider-model-config.test.mjs b/apps/desktop/test/provider-model-config.test.mjs
index c00ba86c4d..4f22674f28 100644
--- a/apps/desktop/test/provider-model-config.test.mjs
+++ b/apps/desktop/test/provider-model-config.test.mjs
@@ -283,3 +283,17 @@ test("the chosen pane narrows a long configured list with its own search", () =>
/@media \(max-width: 720px\)\s*\{[\s\S]*?\.provider-chosen-search-wrap/,
);
});
+
+test("selected models reorder from a dedicated handle, not the copyable id", () => {
+ // The handle is the only drag source so selecting an id still copies it.
+ assert.match(pickerSource, /useModelReorder\(visibleChosen, setModels, busy\)/);
+ assert.match(pickerSource, /provider-chosen-reorder/);
+ assert.match(pickerSource, /IconGripVertical/);
+ assert.match(pickerSource, /settings\.reorderModel/);
+ assert.match(pickerSource, /\{\.\.\.reorder\.handleEvents\(binding\.id\)\}/);
+ assert.match(pickerSource, /\{\.\.\.reorder\.rowEvents\(binding\.id\)\}/);
+ assert.doesNotMatch(pickerSource, /provider-chosen-row-id[\s\S]*draggable/);
+ assert.match(styles, /\.provider-chosen-reorder\s*\{/);
+ assert.match(styles, /\.provider-chosen-row\.is-dragging\s*\{/);
+ assert.match(styles, /\.provider-chosen-row\[data-drop-placement\]::after/);
+});
diff --git a/docs/spec/03-runtime/13-model-catalog-and-selection.md b/docs/spec/03-runtime/13-model-catalog-and-selection.md
index c9d9af2fdd..a149ea9cb2 100644
--- a/docs/spec/03-runtime/13-model-catalog-and-selection.md
+++ b/docs/spec/03-runtime/13-model-catalog-and-selection.md
@@ -74,6 +74,32 @@ entitled to it.
- A custom model ID is always accepted, so a gateway without a `/models` route
stays usable.
+### Settings: selected model order
+
+The AI service and OAuth vendor-account editors share the selected-model pane.
+Each selected row has a dedicated reorder handle: drag it before or after
+another visible row, or focus it and press the Up or Down arrow key to move it
+past the neighboring visible row. Reordering is disabled while the form is
+busy or fewer than two selected rows are visible. Dragging text still selects
+it for copying; checkbox, Advanced, and Remove actions keep their existing
+behavior and do not start a reorder.
+
+The complete `models` binding array owns the order. Filtering only hides rows:
+a move inserts the existing binding before or after the visible target in that
+complete array, preserving hidden bindings and their relative order. Model IDs,
+aliases, and advanced overrides travel with their bindings. A canceled drag or
+a drop outside a selected row does not change the draft.
+
+Saving persists the new order through the existing provider update flow, and
+reopening either editor displays it again. Canceling the editor discards its
+unsaved order. The provider's compatibility `defaultModelId` still mirrors the
+first binding on save, so moving a model to the head changes that provider
+default. When the edited service or account is the app's default provider,
+saving also synchronizes the app-level default model to that first binding,
+as the existing save flow does. The app default is unchanged when editing
+another provider, and an explicitly bound session keeps its stored model
+choice. No storage schema or IPC contract changes are required.
+
### Discovery precedence
`providers.listModels` resolves in this order, and the order is load-bearing:
@@ -459,6 +485,11 @@ same model to the check mark, the toggle and the duplicate guard.
output-token entry; overrides stay behind a per-model Advanced disclosure
- [ ] the API-key path and the OAuth vendor-account path use the same live model
list and the same binding shape
+- [ ] selected models can be reordered by drag handle or Up/Down arrow keys in
+ both editors; saving and reopening preserves the order, aliases, and
+ overrides, and a filtered move preserves hidden bindings and their order
+- [ ] canceling a drag or the editor preserves the previous applicable order;
+ busy forms disable reordering, and text-copy and row actions still work
- [ ] an unsaved provider can be probed from the form before it is persisted,
and a saved one reuses its stored secret without a retyped key
- [ ] custom model id path works without catalog hit
diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md
index 0718da9388..7dd504110e 100644
--- a/docs/spec/06-delivery/04-e2e-test-plan.md
+++ b/docs/spec/06-delivery/04-e2e-test-plan.md
@@ -7595,6 +7595,7 @@ identify the platform validation still needed.
| C / D / Quality — Sidebar row states | E2E-LAYOUT-sidebar-row-states |
| A / C / Quality — Sidebar material and settings return | E2E-LAYOUT-sidebar-settings |
| B / F / Security — Provider copy | E2E-PROVIDER-copy-config-without-credentials |
+| B / F / Quality — Selected model order | E2E-MODEL-selected-order-persists |
| A — App startup | E2E-001, E2E-002, E2E-003, E2E-004, E2E-067, E2E-076, E2E-079, E2E-092, E2E-097, E2E-143, E2E-150, E2E-168, E2E-204 |
| B — Model config | E2E-005, E2E-006, E2E-007, E2E-038, E2E-050, E2E-052, E2E-055, E2E-066, E2E-080, E2E-082, E2E-102c, E2E-102d, E2E-102e, E2E-151, E2E-154, E2E-163, E2E-166, E2E-172, E2E-174, E2E-197, E2E-005G, E2E-005J, E2E-199, E2E-201, E2E-202, E2E-203, E2E-205, E2E-206, E2E-209 |
| C — Conversation & stream | E2E-008, E2E-008d, E2E-008e, E2E-008a, E2E-009, E2E-010, E2E-011, E2E-011a, E2E-011b, E2E-011d, E2E-011e, E2E-011g, E2E-031, E2E-040, E2E-047, E2E-048, E2E-048A, E2E-049, E2E-052, E2E-053, E2E-054, E2E-055, E2E-059, E2E-059a, E2E-060c, E2E-060d, E2E-061, E2E-061a, E2E-062, E2E-064, E2E-065, E2E-068, E2E-071, E2E-073, E2E-074, E2E-075, E2E-081, E2E-083, E2E-084, E2E-086, E2E-087, E2E-088, E2E-088b, E2E-089, E2E-090, E2E-COMPOSER-narrow-controls, E2E-094, E2E-095, E2E-096, E2E-097, E2E-098, E2E-099, E2E-102, E2E-102a, E2E-102b, E2E-102c, E2E-102d, E2E-102g, E2E-106, E2E-109, E2E-111, E2E-114, E2E-116, E2E-117, E2E-118, E2E-119, E2E-120, E2E-121, E2E-218, E2E-259, E2E-219, E2E-AGENTS-001, E2E-142, E2E-144, E2E-145, E2E-146, E2E-146a, E2E-147, E2E-151, E2E-154, E2E-155, E2E-158, E2E-159, E2E-161, E2E-162, E2E-166, E2E-172, E2E-173, E2E-174, E2E-177, E2E-178, E2E-179, E2E-180, E2E-182, E2E-183, E2E-187, E2E-198, E2E-199, E2E-202, E2E-203, E2E-207, E2E-208, E2E-CHAT-content-width-handles, E2E-250, E2E-102i, E2E-PLUGIN-session-orchestrator-real-workers, E2E-SUBAGENT-settlement-updates-before-parent-poll, E2E-SUBAGENT-resume-a-settled-delegation |
@@ -7657,6 +7658,7 @@ identify the platform validation still needed.
| M6 | E2E-104, E2E-105, E2E-106, E2E-107, E2E-108, E2E-109, E2E-110, E2E-111, E2E-112, E2E-113, E2E-114, E2E-115, E2E-116, E2E-117, E2E-118, E2E-119, E2E-120, E2E-103, E2E-172 |
| M6+ | E2E-121, E2E-122, E2E-148, E2E-150, E2E-151, E2E-154, E2E-155, E2E-158, E2E-159, E2E-160, E2E-161, E2E-162, E2E-163, E2E-166, E2E-168, E2E-173, E2E-174, E2E-176, E2E-179, E2E-196a, E2E-196b, E2E-196c, E2E-198, E2E-199, E2E-200, E2E-202, E2E-203, E2E-205, E2E-209, E2E-210, E2E-212, E2E-213, E2E-214, E2E-215, E2E-216, E2E-217, E2E-218, E2E-259, E2E-219, E2E-257, E2E-SUBAGENT-settlement-updates-before-parent-poll, E2E-PLUGIN-fs-root-follows-the-calling-session, E2E-SUBAGENT-resume-a-settled-delegation |
| M6+ (Session Orchestrator) | E2E-PLUGIN-session-orchestrator-real-workers |
+| M6+ (Selected model order) | E2E-MODEL-selected-order-persists |
| M6+ (Session list responsiveness) | E2E-SESSION-list-refresh-keeps-desktop-responsive |
| M6+ (Independent session communication) | E2E-SESSION-independent-top-level-communication, E2E-SESSION-hover-card-model-and-links |
| M5 (Chat file references) | E2E-CHAT-shorthand-file-ref-opens-the-matching-file, E2E-CHAT-file-ref-opens-the-surface-that-owns-it |
@@ -10932,6 +10934,58 @@ are withdrawn with ADR 0165.
- **Status**: Unit-covered (`composer-models.test.mjs`,
`provider-model-config.test.mjs`); rendered UI journey Draft (run only in a capable environment when this surface changes)
+#### E2E-MODEL-selected-order-persists: Selected model order survives saving
+
+- **Preconditions**: An AI service and an OAuth vendor account each have at
+ least three selected models. Include a model with an alias and non-default
+ Advanced settings. Record their binding values, the app-level default
+ provider/model, and an explicitly bound session's model choice. A controllable
+ save response is available to hold either editor busy.
+- **Steps**:
+ 1. Open Settings → Model configuration and edit the AI service. Drag the
+ last selected model by its handle before the first row, then drag it
+ after the last row. Confirm the insertion location and resulting order.
+ 2. Configure the selected IDs in this order: `shown-a`, `hidden-a`,
+ `shown-b`, `hidden-b`, `shown-c`. Filter by `shown-`, then drag `shown-c`
+ before `shown-a`. Clear the filter and inspect all five bindings.
+ 3. Apply the filter again. Focus `shown-c`'s reorder handle and press Down,
+ then Up. Confirm it moves past the neighboring visible row each time
+ and focus remains on the moved model's handle. Try Up on the first
+ visible model and Down on the last; neither changes the order.
+ 4. Start a drag and cancel it, then start another and drop outside the
+ selected rows. Neither action changes the draft order.
+ 5. Drag-select and copy a model ID in the discovery list and in the selected
+ pane. Confirm no reorder or checkbox toggle occurs. Click a discovery
+ checkbox, toggle Advanced, edit an alias, and remove a selected model;
+ each action retains its existing behavior without starting a drag.
+ 6. Save the reordered bindings, reopen the editor, then restart the app and
+ reopen it again. Check order, aliases, model IDs, and Advanced settings.
+ Check the provider default and existing session. Repeat with the service
+ set as the app's default provider and with a different default provider.
+ 7. Change the order again, cancel the editor, and reopen it. The last saved
+ order remains.
+ 8. Repeat the drag, keyboard, save/reopen, and cancel checks in the vendor
+ account editor, including a filtered list with hidden selected bindings.
+ 9. Hold a save in flight and try drag and keyboard reordering. Release the
+ save, then filter down to one visible selected row and try again.
+- **Expected**: Both editors persist the complete ordered binding array. In
+ step 2 the full order becomes `shown-c`, `shown-a`, `hidden-a`, `shown-b`,
+ `hidden-b`; the hidden bindings remain present and retain their relative
+ order. Moving a binding never resets its ID, alias, or Advanced overrides.
+ Saving keeps the provider's compatibility default at the head binding. When
+ that service or account is the app's default provider, saving also updates
+ the app's default model to the head, preserving E2E-005A's existing behavior.
+ Editing another provider leaves the app default unchanged. An explicitly
+ bound session keeps its stored model choice.
+ Canceled/outside drags leave the draft unchanged, editor cancellation
+ discards unsaved moves, and busy or single-visible-row forms disable the
+ reorder handles. Text copying and existing row actions remain independent.
+- **Specs linked**: `03-runtime/13-model-catalog-and-selection.md` §2,
+ `03-runtime/12-provider-config-schema.md`; ADR 0114, ADR 0192
+- **Acceptance**: B (model configuration), F (persistence), Quality
+- **Milestone**: M6+
+- **Status**: Documented; full desktop journey has not been run.
+
#### E2E-202: Subagent thinking follows its exact model binding
- **Preconditions**: A configured provider has a model binding marked
diff --git a/docs/zh-CN/spec/03-runtime/13-model-catalog-and-selection.md b/docs/zh-CN/spec/03-runtime/13-model-catalog-and-selection.md
index 72c4b4567e..d2afd1e754 100644
--- a/docs/zh-CN/spec/03-runtime/13-model-catalog-and-selection.md
+++ b/docs/zh-CN/spec/03-runtime/13-model-catalog-and-selection.md
@@ -29,6 +29,24 @@
- 列表标题旁的全选复选框一次勾选或取消当前可见行。搜索过滤时,“全部”只作用于匹配行;过滤外已选模型保持不变。已选绑定保留高级覆盖;新勾选的行采用 `bindingFromModelInfo` 或自定义模型默认值。可见行全部选中时为勾选,全部未选时为空,部分选中时为不确定态。
- 同一标题旁的「获取列表」会立刻向服务探测,跳过 600 ms 编辑防抖和先画缓存。没有可探测端点、正在探测或表单保存时不可用;空闲但端点已有效(防抖等待)时仍可点,以便跳过该窗口。加载期间保留现有行。凭据变更触发的自动发现不变。
+### 设置:已选模型顺序
+
+AI 服务和 OAuth 厂商账户编辑器共用已选模型面板。每个已选行都有独立的排序手柄:
+可拖到另一个可见行之前或之后,也可让手柄获得焦点,按上、下方向键越过相邻的可见行。
+表单忙碌或可见已选行不足两个时禁用排序。拖选文本仍用于复制;复选框、高级和移除操作
+保持原有行为,不会开始排序。
+
+完整的 `models` 绑定数组决定顺序。过滤只隐藏行:移动时在完整数组中,将原绑定插入
+可见目标之前或之后,保留隐藏绑定及其相对顺序。模型 ID、别名和高级覆盖设置随绑定
+一起移动。取消拖动或在已选行之外释放,不会更改草稿。
+
+保存通过现有提供商更新流程持久化新顺序,重新打开任一编辑器时会再次显示该顺序。
+取消编辑器会丢弃未保存的排序。提供商的兼容字段 `defaultModelId` 仍在保存时反映
+首个绑定,因此将模型移至首位会改变该提供商的默认模型。编辑的服务或账户是应用
+默认提供商时,保存还会按现有流程将应用级默认模型同步为首个绑定。编辑其他提供商
+不会改变应用默认值,明确绑定模型的会话也会保留其已存储的模型选择。无需修改存储
+结构或 IPC 合约。
+
### 物品显示
- 模型显示名称
- 模型 ID
@@ -283,6 +301,10 @@ Electron 使用本地 `models.dev` 记录装饰缓存和新发现的模型行。
## 14. 验收标准
- [ ] 搜索可查找跨多个提供商的模型
+- [ ] 两个编辑器都可通过拖动手柄或上、下方向键调整已选模型顺序;保存后重新打开会
+ 保留顺序、别名和覆盖设置,过滤后的移动会保留隐藏绑定及其顺序
+- [ ] 取消拖动或取消编辑器会保留相应的原顺序;表单忙碌时禁用排序,文本复制和行操作
+ 仍正常工作
- [ ] 自定义模型 ID 路径无需目录命中即可工作
- [ ] 最近的模型出现在选择器中
- [ ] 刷新合并到缓存和选择器中(绝不破坏性替换)
diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
index 27c561ee68..88860bf618 100644
--- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
+++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
@@ -5041,6 +5041,7 @@ IPC 请求无法关闭。
| C / D / Quality — 侧边栏行状态 | E2E-LAYOUT-sidebar-row-states |
| A / C / Quality — 侧栏材质与设置返回 | E2E-LAYOUT-sidebar-settings |
| B / F / Security — 提供商复制 | E2E-PROVIDER-copy-config-without-credentials |
+| B / F / Quality — 已选模型顺序 | E2E-MODEL-selected-order-persists |
| A — 应用程序启动 | E2E-001、E2E-002、E2E-003、E2E-004、E2E-067、E2E-076、E2E-079、E2E-092、E2E-097、E2E-143、E2E-150、E2E-168、E2E-204、E2E-217 |
| B——模型配置 | E2E-005、E2E-005G、E2E-006、E2E-007、E2E-038、E2E-050、E2E-052、E2E-055、E2E-066、E2E-080、E2E-082、E2E-151、E2E-005J、E2E-199、E2E-201、E2E-202、E2E-203、E2E-209、E2E-166 |
| C — 对话和直播 | E2E-008、E2E-008d、E2E-008a、E2E-009、E2E-010、E2E-011、E2E-011a、E2E-011b、E2E-031、E2E-040、E2E-047、E2E-048、E2E-048A、E2E-049、E2E-052、 E2E-053、E2E-054、E2E-055、E2E-059、E2E-059a、E2E-060c、E2E-060d、E2E-061、E2E-061a、E2E-062、E2E-064、E2E-065、E2E-068、E2E-071、 E2E-073、E2E-074、E2E-075、E2E-081、E2E-083、E2E-084、E2E-086、E2E-087、E2E-088、E2E-088b、E2E-089、E2E-090、E2E-094、E2E-095、E2E-096、 E2E-097、E2E-098、E2E-099、E2E-102、E2E-102a、E2E-102b、E2E-106、E2E-109、E2E-111、E2E-114、E2E-116、E2E-117、E2E-118、E2E-119、 E2E-120、E2E-121、E2E-代理-001、E2E-142、E2E-144、E2E-145、E2E-146、E2E-147、E2E-151、E2E-199、E2E-250、E2E-166、E2E-SUBAGENT-resume-a-settled-delegation |
@@ -5103,6 +5104,7 @@ IPC 请求无法关闭。
| M6 | E2E-104、E2E-105、E2E-106、E2E-107、E2E-108、E2E-109、E2E-110、E2E-111、E2E-112、E2E-113、E2E-114、E2E-115、E2E-116、E2E-117、 E2E-118、E2E-119、E2E-120、E2E-103 |
| M6+ | E2E-121、E2E-122、E2E-123、E2E-142、E2E-148、E2E-150、E2E-151、E2E-168、E2E-199、E2E-200、E2E-202、E2E-203、E2E-209、E2E-211、E2E-212、E2E-213、E2E-214、E2E-215、E2E-216、E2E-217、E2E-257、E2E-166、E2E-SUBAGENT-resume-a-settled-delegation |
| M6+(Session Orchestrator) | E2E-PLUGIN-session-orchestrator-real-workers |
+| M6+(已选模型顺序) | E2E-MODEL-selected-order-persists |
| M6+(会话列表响应性) | E2E-SESSION-list-refresh-keeps-desktop-responsive |
| M6+(独立会话通信) | E2E-SESSION-independent-top-level-communication、E2E-SESSION-hover-card-model-and-links |
| M5(聊天文件引用) | E2E-CHAT-shorthand-file-ref-opens-the-matching-file、E2E-CHAT-file-ref-opens-the-surface-that-owns-it |
@@ -6889,6 +6891,45 @@ IPC 请求无法关闭。
- **里程碑**:M2
- **状态**:单元已覆盖(`composer-models.test.mjs`、`provider-model-config.test.mjs`);完整 UI 旅程为草稿
+#### E2E-MODEL-selected-order-persists:保存后保留已选模型顺序
+
+- **前提条件**:一个 AI 服务和一个 OAuth 厂商账户各有至少三个已选模型。其中一个模型
+ 具有别名和非默认的高级设置。记录绑定值、应用级默认提供商和模型,以及一个明确绑定
+ 模型的会话的模型选择。保存响应可控,能够让任一编辑器保持忙碌状态。
+- **步骤**:
+ 1. 打开设置 → 模型配置并编辑 AI 服务。通过手柄将最后一个已选模型拖到首行之前,
+ 再将其拖到末行之后。确认插入位置和最终顺序。
+ 2. 将已选 ID 配置为以下顺序:`shown-a`、`hidden-a`、`shown-b`、`hidden-b`、
+ `shown-c`。按 `shown-` 过滤,再将 `shown-c` 拖到 `shown-a` 之前。清除过滤并
+ 检查全部五个绑定。
+ 3. 再次应用过滤。让 `shown-c` 的排序手柄获得焦点,先按下方向键,再按上方向键。
+ 确认每次都会越过相邻的可见行,且焦点始终保留在已移动模型的手柄上。对首个可见
+ 模型按上方向键,对最后一个可见模型按下方向键;顺序均不改变。
+ 4. 开始拖动后取消,再次开始拖动并在已选行之外释放。两种操作都不改变草稿顺序。
+ 5. 在发现列表和已选面板中拖选并复制模型 ID。确认没有排序或复选框切换。点击发现
+ 列表复选框、展开或收起高级设置、编辑别名、移除已选模型;每种操作均保持原有
+ 行为,不会开始拖动。
+ 6. 保存重新排序后的绑定并重新打开编辑器,再重启应用并重新打开。检查顺序、别名、
+ 模型 ID 和高级设置。检查提供商默认值和现有会话。分别在该服务是应用默认提供商
+ 以及另一个提供商是默认提供商时重复验证。
+ 7. 再次更改顺序,取消编辑器并重新打开。上次保存的顺序保持不变。
+ 8. 在厂商账户编辑器中重复拖动、键盘、保存后重新打开和取消检查,包括过滤后隐藏
+ 部分已选绑定的列表。
+ 9. 暂停保存响应,尝试拖动和键盘排序。恢复保存响应,再过滤到只剩一个可见已选行
+ 并再次尝试。
+- **预期**:两个编辑器都持久化完整的有序绑定数组。步骤 2 后完整顺序变为 `shown-c`、
+ `shown-a`、`hidden-a`、`shown-b`、`hidden-b`;隐藏绑定仍然存在,并保留其相对
+ 顺序。移动绑定不会重置其 ID、别名或高级覆盖设置。保存后提供商的兼容默认值仍为
+ 首个绑定。该服务或账户是应用默认提供商时,保存也会将应用默认模型更新为首个绑定,
+ 保留 E2E-005A 的既有行为。编辑其他提供商不会改变应用默认值。明确绑定模型的会话
+ 保留其已存储的模型选择。取消拖动或在行外释放不会改变草稿;取消编辑器会丢弃未保存
+ 的移动;表单忙碌或只有一个可见行时禁用排序手柄。文本复制和现有行操作保持独立。
+- **链接规格**:`03-runtime/13-model-catalog-and-selection.md` §2、
+ `03-runtime/12-provider-config-schema.md`;ADR 0114、ADR 0192
+- **验收**:B(模型配置)、F(持久化)、质量
+- **里程碑**:M6+
+- **状态**:已记录;完整桌面旅程尚未运行。
+
#### E2E-202:子智能体思考跟随其精确模型绑定
- **前提条件**:已配置提供商有标记 `availableForSubagents` 的模型绑定。目录将该模型标为非推理,或未发布绑定显式启用的某一档。用户子智能体定义和内置 `Task` 目录可用。
diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts
index 27c28fa704..300b9e41d2 100644
--- a/packages/i18n/src/locales/de/index.ts
+++ b/packages/i18n/src/locales/de/index.ts
@@ -1102,6 +1102,7 @@ sklm: {
"modelAlreadyAdded": "Modell wurde bereits hinzugefügt",
"modelConfigurations": "Modelleinstellungen",
"removeModel": "Modell entfernen",
+ "reorderModel": "Reihenfolge von {{name}} ändern. Ziehen oder die Pfeiltasten nach oben und unten verwenden.",
"modelAlias": "Alias",
"modelAliasHint": "Wird überall angezeigt, wo das Modell benannt wird; die ID bleibt die Identität der Anfrage.",
"modelAliasPlaceholder": "z. B. schnell",
diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts
index 2134fbd8d2..05a82a850d 100644
--- a/packages/i18n/src/locales/en/index.ts
+++ b/packages/i18n/src/locales/en/index.ts
@@ -1123,6 +1123,7 @@ sklm: {
modelAlreadyAdded: "Model already added",
modelConfigurations: "Model settings",
removeModel: "Remove model",
+ reorderModel: "Reorder {{name}}. Drag or use the Up and Down arrow keys.",
modelAlias: "Alias",
modelAliasHint: "Shown wherever the model is named. The id stays the request identity.",
modelAliasPlaceholder: "e.g. fast",
diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts
index df756ed09a..aaea516687 100644
--- a/packages/i18n/src/locales/es/index.ts
+++ b/packages/i18n/src/locales/es/index.ts
@@ -1102,6 +1102,7 @@ sklm: {
"modelAlreadyAdded": "Modelo ya agregado",
"modelConfigurations": "Configuración del modelo",
"removeModel": "Eliminar modelo",
+ "reorderModel": "Reordenar {{name}}. Arrastra o usa las flechas arriba y abajo.",
"modelAlias": "Alias",
"modelAliasHint": "Se muestra dondequiera que se nombre el modelo; el id sigue siendo la identidad de la solicitud.",
"modelAliasPlaceholder": "p. ej. rápido",
diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts
index da0e45b812..449c75eb02 100644
--- a/packages/i18n/src/locales/fr/index.ts
+++ b/packages/i18n/src/locales/fr/index.ts
@@ -1102,6 +1102,7 @@ sklm: {
"modelAlreadyAdded": "Modèle déjà ajouté",
"modelConfigurations": "Paramètres du modèle",
"removeModel": "Supprimer le modèle",
+ "reorderModel": "Réordonner {{name}}. Faites glisser ou utilisez les flèches haut et bas.",
"modelAlias": "Alias",
"modelAliasHint": "Affiché partout où le modèle est nommé ; l'id reste l'identité de la requête.",
"modelAliasPlaceholder": "ex. rapide",
diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts
index 5c4972b486..cde33190e8 100644
--- a/packages/i18n/src/locales/ko/index.ts
+++ b/packages/i18n/src/locales/ko/index.ts
@@ -1123,6 +1123,7 @@ sklm: {
modelAlreadyAdded: "모델이 이미 추가됨",
modelConfigurations: "모델 설정",
removeModel: "모델 제거",
+ reorderModel: "{{name}} 순서 변경. 드래그하거나 위쪽 및 아래쪽 화살표 키를 사용하세요.",
modelAlias: "별칭",
modelAliasHint: "모델 이름이 표시되는 곳마다 사용됩니다. ID는 요청 식별자로 유지됩니다.",
modelAliasPlaceholder: "예: fast",
diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts
index 7f16562225..01ae83b75d 100644
--- a/packages/i18n/src/locales/tr/index.ts
+++ b/packages/i18n/src/locales/tr/index.ts
@@ -1123,6 +1123,7 @@ sklm: {
modelAlreadyAdded: "Model zaten eklendi",
modelConfigurations: "Model ayarları",
removeModel: "Modeli kaldır",
+ reorderModel: "{{name}} sırasını değiştir. Sürükleyin veya Yukarı ve Aşağı ok tuşlarını kullanın.",
modelAlias: "Takma ad",
modelAliasHint: "Modelin adlandırıldığı her yerde gösterilir; istek kimliği model ID olarak kalır.",
modelAliasPlaceholder: "örn. hızlı",
diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts
index c1af4dbd01..658c2ce882 100644
--- a/packages/i18n/src/locales/zh-CN/index.ts
+++ b/packages/i18n/src/locales/zh-CN/index.ts
@@ -1106,6 +1106,7 @@ sklm: {
modelAlreadyAdded: "该模型已添加",
modelConfigurations: "模型设置",
removeModel: "移除模型",
+ reorderModel: "调整 {{name}} 的顺序。拖动或使用上下方向键。",
modelAlias: "别名",
modelAliasHint: "在显示模型名称的地方生效;请求仍使用模型 ID。",
modelAliasPlaceholder: "例如 fast",
diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts
index 30416065d8..35060e2b64 100644
--- a/packages/i18n/src/locales/zh-TW/index.ts
+++ b/packages/i18n/src/locales/zh-TW/index.ts
@@ -1106,6 +1106,7 @@ sklm: {
modelAlreadyAdded: "該模型已新增",
modelConfigurations: "模型設定",
removeModel: "移除模型",
+ reorderModel: "調整 {{name}} 的順序。拖曳或使用上下方向鍵。",
modelAlias: "別名",
modelAliasHint: "在顯示模型名稱的地方生效;請求仍使用模型 ID。",
modelAliasPlaceholder: "例如 fast",