Skip to content

Commit 0f22ae1

Browse files
sanchitrkclaude
andcommitted
feat: list models over RPC and pick model and reasoning on the start page
model.list returns the providers the server holds credentials for, with each model's supported thinking levels, and the settings default for a session anchored at hostDir. The app server proxies it as models.list. The start page composer gets a model picker (provider rail, search, provider logos) and a Reasoning picker in its bottom row. The settings default stays in view, muted, when its provider has no credentials. The picks are kept in localStorage and sent with the create request; without them settings decide. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a6c1c0f commit 0f22ae1

15 files changed

Lines changed: 842 additions & 55 deletions

File tree

‎apps/server/src/contract.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Schema } from "effect";
22
import { Rpc, RpcGroup } from "effect/rpc";
3+
import { ModelList } from "@codeworksh/widget/codework";
34
import { Event, EventTimeout, Publish, Request, Synced } from "@codeworksh/widget/event";
45

56
/** A conversation with the agent, as listed under its project. */
@@ -26,6 +27,11 @@ export class CodeworkUnavailable extends Schema.TaggedError<CodeworkUnavailable>
2627
message: Schema.String,
2728
}) {}
2829

30+
/** The models could not be listed: a settings file or the model catalog is broken. */
31+
export class ModelsError extends Schema.TaggedError<ModelsError>()("Server.ModelsError", {
32+
message: Schema.String,
33+
}) {}
34+
2935
export const Layout = Schema.Literals(["tile", "monocle"]);
3036
export type Layout = typeof Layout.Type;
3137

@@ -75,6 +81,12 @@ export const Api = RpcGroup.make(
7581
success: Schema.Array(Project),
7682
error: CodeworkUnavailable,
7783
}),
84+
/** The models a new session can use; `hostDir` is its folder, whose project settings apply. */
85+
Rpc.make("models.list", {
86+
payload: { hostDir: Schema.optionalKey(Schema.String) },
87+
success: ModelList,
88+
error: Schema.Union([CodeworkUnavailable, ModelsError]),
89+
}),
7890
Rpc.make("workspaces.list", {
7991
success: Schema.Array(Workspace),
8092
}),

‎apps/server/src/handlers.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Effect } from "effect";
22
import { Codework } from "./codework.ts";
3-
import { Contract, type Project } from "./contract.ts";
3+
import { Contract, ModelsError, type Project } from "./contract.ts";
44
import { Event } from "./event.ts";
55
import { Kv } from "./kv.ts";
66
import { Workspaces } from "./workspaces.ts";
@@ -46,6 +46,21 @@ export const layer = Contract.Api.toLayer(
4646
return byProject(sessions);
4747
}),
4848

49+
// The catalog's own errors are kept apart from an unreachable server, so their detail shows.
50+
"models.list": Effect.fn("models.list")(function* ({ hostDir }) {
51+
const listed = yield* Codework.call((client) =>
52+
client("model.list", hostDir === undefined ? {} : { hostDir }).pipe(
53+
Effect.map((list) => ({ list })),
54+
Effect.catchTags({
55+
SettingsError: (error) => Effect.succeed({ failed: `${error.path}: ${error.detail}` }),
56+
"Runner.ModelCatalogError": (error) => Effect.succeed({ failed: error.detail }),
57+
}),
58+
),
59+
);
60+
if ("failed" in listed) return yield* new ModelsError({ message: listed.failed });
61+
return listed.list;
62+
}),
63+
4964
"kv.get": ({ package: pkg, key }) => kv.get(pkg, key),
5065
"kv.set": ({ package: pkg, key, value }) => kv.set(pkg, key, value),
5166

‎apps/web/src/components/composer.tsx‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export function Composer({
1919
placeholder,
2020
autoFocus,
2121
compact = false,
22+
controls,
2223
actions,
2324
tray,
2425
}: {
@@ -29,6 +30,8 @@ export function Composer({
2930
readonly autoFocus?: boolean;
3031
/** One line that grows with its text, for a composer sharing a frame with what it talks to. */
3132
readonly compact?: boolean;
33+
/** Pickers at the bottom left, such as the model; `actions` sit at the bottom right. */
34+
readonly controls?: ReactNode;
3235
readonly actions: ReactNode;
3336
readonly tray?: ReactNode;
3437
}) {
@@ -56,10 +59,14 @@ export function Composer({
5659
placeholder={placeholder}
5760
aria-label="Prompt"
5861
// Grows with its text, from four lines (one when compact) up to about nine, then scrolls.
59-
className={`block max-h-52 w-full resize-none bg-transparent text-[14px] leading-relaxed text-ink outline-none [field-sizing:content] placeholder:text-ink-muted/60 ${compact ? "min-h-6" : "min-h-17.5"}`}
62+
className={`block max-h-52 w-full resize-none bg-transparent text-[14px] leading-relaxed text-ink outline-none [field-sizing:content] placeholder:text-ink-muted/60 ${compact ? "min-h-6" : "min-h-19.5"}`}
6063
/>
6164
</div>
62-
<div className={`flex items-center justify-end gap-2 ${compact ? "px-2.5 pb-2.5" : "px-4 pb-4"}`}>
65+
<div className={`flex items-center gap-2 ${compact ? "px-2.5 pb-2.5" : "px-4 pb-4"}`}>
66+
{/* Pulled left by a control's padding, so the first one's icon lines up with the prompt's text. */}
67+
<div className="-ml-2.5 flex min-w-0 flex-1 items-center gap-1 text-[14px] font-medium text-ink-muted">
68+
{controls}
69+
</div>
6370
{actions}
6471
</div>
6572
</form>
@@ -93,7 +100,7 @@ export function ComposerButton({
93100
title={label}
94101
disabled={disabled}
95102
onClick={onClick}
96-
className="grid size-8 place-items-center rounded-full bg-brand text-brand-ink shadow-xs transition-[opacity,scale] duration-150 enabled:hover:scale-105 disabled:opacity-30"
103+
className="grid size-8 shrink-0 place-items-center rounded-full bg-brand text-brand-ink shadow-xs transition-[opacity,scale,filter] duration-150 enabled:hover:scale-105 enabled:hover:brightness-110 enabled:active:scale-100 disabled:opacity-64"
97104
>
98105
{children}
99106
</button>

‎apps/web/src/landing/landing.tsx‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { makeHost } from "../kernel/host";
1717
import { sessionAddress } from "../widgets/session/address";
1818
import { Json, type Place as Where, topics } from "../widgets/session/schema";
1919
import { useOpenElsewhere } from "../wm/open";
20+
import { ModelPicker, ThinkingPicker, useChoice } from "./model";
2021
import { projectsAtom, SessionsSidebar, TitleBar } from "./sidebar";
2122
import { Wordmark } from "./wordmark";
2223

@@ -25,9 +26,6 @@ const bridge = window.desktopBridge;
2526
const encodeCreate = Schema.encodeSync(Json.Create);
2627
const decodeCreated = Schema.decodeUnknownOption(Json.Created);
2728

28-
// Until the start page has a provider and model selector, every new session uses this one.
29-
const MODEL = { provider: "openai-codex", id: "gpt-6-luna" };
30-
3129
/** Where a new project lives: nowhere yet, only in memory, or a folder on disk. */
3230
type Place = { kind: "none" } | { kind: "memory" } | { kind: "folder"; path: string };
3331

@@ -53,6 +51,11 @@ export function Landing() {
5351
const [place, setPlace] = useState<Place>({ kind: "none" });
5452
const [pending, setPending] = useState(false);
5553
const [error, setError] = useState<string>();
54+
// Remembered across restarts; an absent pick leaves it to settings.
55+
const [choice, setChoice] = useChoice();
56+
const { model, thinkingLevel: thinking } = choice;
57+
// A folder's project settings may add models or change the default; memory has only the user's.
58+
const hostDir = place.kind === "folder" ? place.path : undefined;
5659

5760
const canSend = place.kind !== "none" && query.trim() !== "" && !pending;
5861

@@ -68,7 +71,8 @@ export function Landing() {
6871
place: where,
6972
messageId: crypto.randomUUID(),
7073
text: query.trim(),
71-
model: MODEL,
74+
...(model === undefined ? {} : { model }),
75+
...(thinking === undefined ? {} : { thinkingLevel: thinking }),
7276
}),
7377
})
7478
.then((reply) => Option.getOrUndefined(decodeCreated(reply.payload)))
@@ -112,6 +116,21 @@ export function Landing() {
112116
onChange={setQuery}
113117
onSubmit={() => void send()}
114118
placeholder="Ask for changes, send follow-ups, or attach images"
119+
controls={
120+
<>
121+
<ModelPicker
122+
hostDir={hostDir}
123+
value={model}
124+
onChange={(next) => setChoice({ ...choice, model: next })}
125+
/>
126+
<ThinkingPicker
127+
hostDir={hostDir}
128+
model={model}
129+
value={thinking}
130+
onChange={(level) => setChoice({ ...choice, thinkingLevel: level })}
131+
/>
132+
</>
133+
}
115134
actions={
116135
<ComposerButton label="Send" disabled={!canSend}>
117136
<ArrowUp className="size-4" strokeWidth={2} aria-hidden />

0 commit comments

Comments
 (0)