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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ NeoCode is a distinct product built from upstream T3 Code (`pingdotgg/t3code`),
| `apps/web/src/components/chat/ChatComposer.tsx` | Optional provider-owned composer projection, editor action, command label, and skill insertion hooks |
| `packages/contracts/src/{settings,model,providerRuntime,server,usage}.ts`, `packages/contracts/src/settings.test.ts` | Provider contracts and NeoPi's enabled-by-default configuration; optional automatic-executable provenance; neutral client-facing settings guidance; preserve explicit opt-out and optional-provider boundaries |
| `apps/web/src/components/settings/ProviderSettingsPanel.tsx` | Mount the NeoPi-owned automatic-executable selection note beside the saved binary setting |
| `apps/web/src/components/settings/ProviderModelsSection.tsx`, `docs/user/composer.md` | Keep large provider catalogs responsive with the existing virtual-list component; preserve full-catalog filtering, model preferences and custom-model reveal |
| `apps/server/src/orchestration/{ActivityPayloadProjection,ActivityPayloadProjection.test}.ts` | Provider-neutral opt-in bounded command output for expanded activity rows; preserve complete Unicode code points at the truncation boundary |
| `packages/client-runtime/src/work-log/{presentation,presentation.test}.ts` | Provider-neutral opt-in expanded command output precedence shared by web, desktop, and mobile, with fallback regression coverage |
| `apps/web/src/session-logic.command-output.test.ts` | Regression for expanded command output through the web work-log consumer |
Expand Down
112 changes: 65 additions & 47 deletions apps/web/src/components/settings/ProviderModelsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Input } from "../ui/input";
import { Switch } from "../ui/switch";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { CustomModelEditor } from "./CustomModelEditor";
import { LegendList, LegendListRef } from "@legendapp/list/react";

/**
* Placeholder text for the "add a custom model" input, keyed by driver
Expand Down Expand Up @@ -177,7 +178,7 @@ export function ProviderModelsSection({
const [error, setError] = useState<string | null>(null);
// Slug of the custom model whose inline editor is open, if any.
const [editingSlug, setEditingSlug] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const listRef = useRef<LegendListRef | null>(null);
// Slug of a just-added custom model, scrolled into view once its row exists.
const scrollToSlugRef = useRef<string | null>(null);
const hiddenModelSet = useMemo(() => new Set(hiddenModels), [hiddenModels]);
Expand Down Expand Up @@ -209,17 +210,15 @@ export function ProviderModelsSection({
)
: displayModels;

// The parent commits the new custom model and hands back an updated
// `models` list, so the row can only be scrolled to after that render.
// Reveal a just-added model by index: its row may not be mounted yet.
useEffect(() => {
const slug = scrollToSlugRef.current;
if (slug === null) return;
const row = listRef.current?.querySelector<HTMLElement>(
`[data-model-slug="${CSS.escape(slug)}"]`,
);
if (!row) return;
const index = displayModels.findIndex((model) => model.slug === slug);
if (index < 0 || !listRef.current) return;
scrollToSlugRef.current = null;
row.scrollIntoView({ block: "nearest" });
void listRef.current.scrollToIndex({ index, animated: false, viewPosition: 0.5 });
listRef.current.getScrollableNode().scrollIntoView({ block: "nearest" });
}, [displayModels]);

const handleAdd = () => {
Expand Down Expand Up @@ -505,7 +504,10 @@ export function ProviderModelsSection({
{showFilter ? (
<Input
value={filter}
onChange={(event) => setFilter(event.target.value)}
onChange={(event) => {
setFilter(event.target.value);
void listRef.current?.scrollToOffset({ offset: 0, animated: false });
}}
placeholder="Filter models"
size="sm"
className="w-56 max-w-full"
Expand Down Expand Up @@ -547,49 +549,65 @@ export function ProviderModelsSection({
</Button>
) : null}
</div>
<div
ref={listRef}
className="mt-2 -mx-2 max-h-64 overflow-y-auto lg:max-h-none lg:min-h-0 lg:flex-1"
>
<div className="mt-2 -mx-2">
{visibleModels.length === 0 ? (
<p className="px-2 py-2 text-xs text-muted-foreground">
{isFiltering ? "No models match." : "No models reported for this provider yet."}
</p>
) : null}
{visibleModels.map((model, index) => {
const group = groupOf(model);
const previous = visibleModels[index - 1];
const startsGroup = previous === undefined || groupOf(previous) !== group;
const editingEntry =
model.isCustom && editingSlug === model.slug
? customModels.find((entry) => entry.slug === model.slug)
: undefined;
return (
<div key={`${instanceId}:${model.slug}:group`}>
{startsGroup && favoriteCount > 0 && group === "favorite"
? groupLabel("Favorites", index === 0)
: null}
{startsGroup && favoriteCount > 0 && group === "visible"
? groupLabel("All", index === 0)
: null}
{startsGroup && group === "hidden"
? groupLabel("Hidden from picker", index === 0)
: null}
{renderRow(model)}
{editingEntry ? (
<CustomModelEditor
key={`${instanceId}:${model.slug}:editor`}
instanceId={instanceId}
driverKind={driverKind}
entry={editingEntry}
builtInModels={builtInModels}
onSave={handleSaveEdit}
onCancel={() => setEditingSlug(null)}
/>
) : null}
</div>
);
})}
<LegendList
ref={listRef}
data={visibleModels}
extraData={editingSlug}
keyExtractor={(model) => model.slug}
estimatedItemSize={28}
drawDistance={160}
recycleItems={false}
maintainVisibleContentPosition={false}
style={{
height: Math.min(
visibleModels.length * 28 +
(favoriteCount > 0 ? 80 : 0) +
(hiddenCount > 0 ? 40 : 0) +
(editingSlug !== null ? 320 : 0),
256,
),
}}
renderItem={({ item: model, index }) => {
const group = groupOf(model);
const previous = visibleModels[index - 1];
const startsGroup = previous === undefined || groupOf(previous) !== group;
const editingEntry =
model.isCustom && editingSlug === model.slug
? customModels.find((entry) => entry.slug === model.slug)
: undefined;
return (
<div key={`${instanceId}:${model.slug}:group`}>
{startsGroup && favoriteCount > 0 && group === "favorite"
? groupLabel("Favorites", index === 0)
: null}
{startsGroup && favoriteCount > 0 && group === "visible"
? groupLabel("All", index === 0)
: null}
{startsGroup && group === "hidden"
? groupLabel("Hidden from picker", index === 0)
: null}
{renderRow(model)}
{editingEntry ? (
<CustomModelEditor
key={`${instanceId}:${model.slug}:editor`}
instanceId={instanceId}
driverKind={driverKind}
entry={editingEntry}
builtInModels={builtInModels}
onSave={handleSaveEdit}
onCancel={() => setEditingSlug(null)}
/>
) : null}
</div>
);
}}
/>
</div>

{driverKind === "antigravity" ? null : isAdding ? (
Expand Down
4 changes: 4 additions & 0 deletions docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ On web and desktop, use Settings → Providers → **Models** to add an unlisted
name and options. Only options supported by the provider integration affect turns. Antigravity
uses its account catalog and does not support custom models.

The filter searches the provider's full catalog, including models outside the
visible rows. Favorites, ordering and hidden-model preferences apply to the
whole catalog. Adding a custom model brings its row into view.

## Model defaults

T3 Code remembers your provider, model, and model options for new threads. A
Expand Down
Loading