@saadnvd1/agent-os 0.3.782 → 0.3.786

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/app/api/hosts/[id]/agents/route.ts +6 -4
  2. package/app/api/machine/route.ts +5 -3
  3. package/app/api/whats-new/route.ts +47 -0
  4. package/components/Chat/ChatPanel.tsx +2 -0
  5. package/components/Chat/ModelPicker.tsx +82 -42
  6. package/components/Chat/composer/ComposerBody.tsx +3 -0
  7. package/components/Chat/composer/Toolbar.tsx +3 -0
  8. package/components/Draft/AgentModelChip.test.ts +1 -0
  9. package/components/Draft/AgentModelChip.tsx +36 -93
  10. package/components/Draft/Chip.tsx +27 -0
  11. package/components/Draft/DraftChips.tsx +5 -1
  12. package/components/Draft/DraftPanel.tsx +0 -21
  13. package/components/Draft/catalogBlocker.ts +19 -0
  14. package/components/Draft/picker/AgentModelPicker.tsx +152 -0
  15. package/components/Draft/picker/AgentRail.tsx +145 -0
  16. package/components/Draft/picker/ModelList.tsx +136 -0
  17. package/components/Draft/picker/ModelNote.tsx +15 -0
  18. package/components/Draft/picker/PickerSheet.tsx +61 -0
  19. package/components/Draft/picker/SameAsLast.tsx +52 -0
  20. package/components/Draft/picker/SearchField.tsx +48 -0
  21. package/components/Draft/picker/picker.test.ts +152 -0
  22. package/components/Draft/picker/sections.test.ts +115 -0
  23. package/components/Draft/picker/sections.ts +121 -0
  24. package/components/Draft/picker/useListKeys.ts +54 -0
  25. package/components/Draft/picker/useModelSearch.ts +66 -0
  26. package/components/SidebarFooter.tsx +3 -1
  27. package/components/Updates/UpdateBanner.test.ts +1 -1
  28. package/components/Updates/UpdateBanner.tsx +27 -13
  29. package/components/WhatsNew/WhatsNewButton.tsx +36 -0
  30. package/components/WhatsNew/WhatsNewDialog.tsx +169 -0
  31. package/components/WhatsNew/WhatsNewHost.tsx +17 -0
  32. package/components/WhatsNew/WhatsNewRows.tsx +62 -0
  33. package/components/WhatsNew/index.ts +2 -0
  34. package/components/ui/dialog.tsx +12 -1
  35. package/content/whats-new.json +1773 -0
  36. package/data/chat/useChat.ts +3 -0
  37. package/data/machines/index.ts +13 -4
  38. package/data/machines/keys.ts +2 -1
  39. package/data/whats-new/index.ts +44 -0
  40. package/hooks/useWhatsNewSeen.ts +50 -0
  41. package/lib/chat/drivers/copilot.ts +9 -6
  42. package/lib/chat/drivers/opencode.ts +1 -1
  43. package/lib/chat/drivers/pi.ts +1 -1
  44. package/lib/chat/events.ts +6 -0
  45. package/lib/chat/settings.ts +2 -0
  46. package/lib/machines/catalog.ts +5 -1
  47. package/lib/machines/recent.ts +31 -0
  48. package/lib/machines/remote.ts +28 -9
  49. package/lib/machines/report.ts +7 -2
  50. package/lib/machines/types.ts +12 -0
  51. package/lib/orchestrator/review-prompt.ts +2 -0
  52. package/lib/security/demo.ts +2 -0
  53. package/lib/tasks/brief.ts +1 -0
  54. package/lib/whats-new/ahead.ts +28 -0
  55. package/lib/whats-new/areas.ts +76 -0
  56. package/lib/whats-new/claude.ts +35 -0
  57. package/lib/whats-new/collect.ts +68 -0
  58. package/lib/whats-new/generate.ts +107 -0
  59. package/lib/whats-new/lint.ts +69 -0
  60. package/lib/whats-new/load.ts +59 -0
  61. package/lib/whats-new/rows.ts +79 -0
  62. package/lib/whats-new/schema.ts +41 -0
  63. package/lib/whats-new/summarize.ts +91 -0
  64. package/lib/whats-new/unread.ts +14 -0
  65. package/package.json +2 -1
  66. package/scripts/autodeploy +28 -0
  67. package/scripts/autodeploy.test.ts +53 -0
  68. package/scripts/whats-new.ts +118 -0
  69. package/stores/whatsNew.ts +16 -0
  70. package/components/Draft/draftModels.test.ts +0 -31
  71. package/components/Draft/draftModels.ts +0 -17
@@ -7,16 +7,18 @@ import type { HostCatalogResponse } from "@/lib/machines/types";
7
7
  export const dynamic = "force-dynamic";
8
8
 
9
9
  // The agents and models a machine reports, for the new-session form: this
10
- // machine's own, or a linked machine's through its AgentOS.
10
+ // machine's own, or a linked machine's through its AgentOS. ?project= adds
11
+ // what that project last ran with.
11
12
  export async function GET(
12
- _request: Request,
13
+ request: Request,
13
14
  { params }: { params: Promise<{ id: string }> }
14
15
  ) {
15
16
  const { id } = await params;
16
17
  if (!getHost(id))
17
18
  return NextResponse.json({ error: "Unknown machine" }, { status: 404 });
19
+ const project = new URL(request.url).searchParams.get("project") || null;
18
20
  const body: HostCatalogResponse = isRemoteHost(id)
19
- ? await hostCatalog(id)
20
- : { catalog: await machineCatalog(), error: null };
21
+ ? await hostCatalog(id, project)
22
+ : { catalog: await machineCatalog(project), error: null };
21
23
  return NextResponse.json(body);
22
24
  }
@@ -4,7 +4,9 @@ import { thisMachineReport } from "@/lib/machines/report";
4
4
  export const dynamic = "force-dynamic";
5
5
 
6
6
  // This machine's name, OS, addresses, agents and models. A linked machine
7
- // asks for it (it is on the peer allowlist) to fill the new-session form.
8
- export async function GET() {
9
- return NextResponse.json(await thisMachineReport());
7
+ // asks for it (it is on the peer allowlist) to fill the new-session form;
8
+ // ?project= adds what that project last ran with.
9
+ export async function GET(request: Request) {
10
+ const project = new URL(request.url).searchParams.get("project");
11
+ return NextResponse.json(await thisMachineReport(project || null));
10
12
  }
@@ -0,0 +1,47 @@
1
+ import { createHash } from "crypto";
2
+ import { NextRequest, NextResponse } from "next/server";
3
+ import { z } from "zod";
4
+ import { cachedCheck } from "@/lib/updates/check";
5
+ import { aheadEntries } from "@/lib/whats-new/ahead";
6
+ import { headOf, loadEntries, pageOf } from "@/lib/whats-new/load";
7
+
8
+ const query = z.object({
9
+ head: z.literal("1").optional(),
10
+ ahead: z.literal("1").optional(),
11
+ before: z
12
+ .string()
13
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
14
+ .optional(),
15
+ limit: z.coerce.number().int().min(1).max(200).default(60),
16
+ });
17
+
18
+ // GET /api/whats-new?head=1 - the newest id and the count, for the footer's
19
+ // dot. With ahead=1: what a waiting update would add. Otherwise a page of entries, newest first, older than `before`.
20
+ // Reads a static file; nothing here calls a model.
21
+ export async function GET(request: NextRequest) {
22
+ const parsed = query.safeParse(
23
+ Object.fromEntries(request.nextUrl.searchParams)
24
+ );
25
+ if (!parsed.success)
26
+ return NextResponse.json({ error: "Bad query" }, { status: 400 });
27
+ const { head, ahead, before, limit } = parsed.data;
28
+ const entries = loadEntries();
29
+ const check = ahead ? cachedCheck() : null;
30
+ const body = ahead
31
+ ? {
32
+ entries: check?.available
33
+ ? await aheadEntries(check.latest?.sha, entries)
34
+ : [],
35
+ }
36
+ : head
37
+ ? headOf(entries)
38
+ : pageOf(entries, { before, limit });
39
+ const text = JSON.stringify(body);
40
+ const etag = `"${createHash("sha1").update(text).digest("hex").slice(0, 16)}"`;
41
+ const headers = { ETag: etag, "Cache-Control": "private, no-cache" };
42
+ if (request.headers.get("if-none-match") === etag)
43
+ return new NextResponse(null, { status: 304, headers });
44
+ return new NextResponse(text, {
45
+ headers: { ...headers, "Content-Type": "application/json" },
46
+ });
47
+ }
@@ -291,6 +291,7 @@ export function ChatPanel({
291
291
  models,
292
292
  model,
293
293
  modelLabel,
294
+ agentName,
294
295
  send,
295
296
  interrupt,
296
297
  setModel,
@@ -881,6 +882,7 @@ export function ChatPanel({
881
882
  models={models}
882
883
  model={model}
883
884
  modelLabel={modelLabel}
885
+ agentName={agentName}
884
886
  onSetModel={setModel}
885
887
  access={access}
886
888
  onSetAccess={accessLocked ? undefined : setAccess}
@@ -1,13 +1,16 @@
1
1
  "use client";
2
2
 
3
- import { Check, ChevronDown } from "lucide-react";
4
- import {
5
- DropdownMenu,
6
- DropdownMenuContent,
7
- DropdownMenuItem,
8
- DropdownMenuTrigger,
9
- } from "@/components/ui/dropdown-menu";
3
+ import { useRef } from "react";
4
+ import { ChevronDown, Lock } from "lucide-react";
10
5
  import type { ChatModel } from "@/lib/chat/events";
6
+ import { ModelList } from "@/components/Draft/picker/ModelList";
7
+ import { PickerSheet } from "@/components/Draft/picker/PickerSheet";
8
+ import { SearchField } from "@/components/Draft/picker/SearchField";
9
+ import {
10
+ focusSelectedRow,
11
+ useListKeys,
12
+ } from "@/components/Draft/picker/useListKeys";
13
+ import { useModelSearch } from "@/components/Draft/picker/useModelSearch";
11
14
 
12
15
  export function modelLabel(
13
16
  models: ChatModel[],
@@ -17,11 +20,25 @@ export function modelLabel(
17
20
  return models.find((m) => m.value === value)?.label ?? (serverLabel || value);
18
21
  }
19
22
 
23
+ /**
24
+ * The list value a chat's model is: itself, or the family alias when the chat
25
+ * holds a full id ("claude-opus-5-5" is the "opus" row).
26
+ */
27
+ export function currentModelValue(
28
+ models: ChatModel[],
29
+ model: string
30
+ ): string | undefined {
31
+ if (models.some((m) => m.value === model)) return model;
32
+ const family = /^claude-([a-z]+)-\d/.exec(model)?.[1];
33
+ return family ? models.find((m) => m.value === family)?.value : undefined;
34
+ }
35
+
20
36
  // The session's model, switchable mid-conversation. Opened by its button or /model.
21
37
  export function ModelPicker({
22
38
  models,
23
39
  model,
24
40
  label,
41
+ agentName,
25
42
  open,
26
43
  onOpenChange,
27
44
  onPick,
@@ -29,47 +46,70 @@ export function ModelPicker({
29
46
  models: ChatModel[];
30
47
  model: string;
31
48
  label?: string;
49
+ agentName?: string;
32
50
  open: boolean;
33
51
  onOpenChange: (open: boolean) => void;
34
52
  onPick: (value: string) => void;
35
53
  }) {
54
+ const current = currentModelValue(models, model);
55
+ const root = useRef<HTMLDivElement>(null);
56
+ const search = useRef<HTMLInputElement>(null);
57
+ const { query, setQuery, sections } = useModelSearch({
58
+ agent: { agent: "chat", label: "", models },
59
+ currentValue: current,
60
+ open,
61
+ });
62
+ const onKeyDown = useListKeys(root, search);
36
63
  if (!model && models.length === 0) return null;
37
64
  return (
38
- <DropdownMenu open={open} onOpenChange={onOpenChange}>
39
- <DropdownMenuTrigger asChild>
40
- <button
41
- type="button"
42
- aria-label="Model"
43
- className="text-muted-foreground hover:text-foreground hover:bg-foreground/[0.05] flex h-10 max-w-36 min-w-0 items-center gap-1 rounded-lg px-2 text-xs"
44
- >
45
- <span className="truncate">
46
- {modelLabel(models, model, label) || "Model"}
47
- </span>
48
- <ChevronDown className="h-3 w-3 shrink-0" />
49
- </button>
50
- </DropdownMenuTrigger>
51
- <DropdownMenuContent align="start" side="top" className="max-w-80">
52
- {models.map((m) => (
53
- <DropdownMenuItem
54
- key={m.value}
55
- onSelect={() => onPick(m.value)}
56
- disabled={!!m.disabled}
57
- className="flex min-h-11 items-start gap-2 md:min-h-9"
58
- >
59
- <Check
60
- className={`mt-0.5 h-3.5 w-3.5 shrink-0 ${m.value === model ? "opacity-100" : "opacity-0"}`}
65
+ <>
66
+ <button
67
+ type="button"
68
+ aria-label="Model"
69
+ aria-haspopup="dialog"
70
+ onClick={() => onOpenChange(true)}
71
+ className="text-muted-foreground hover:text-foreground hover:bg-foreground/[0.05] flex h-10 max-w-36 min-w-0 items-center gap-1 rounded-lg px-2 text-xs"
72
+ >
73
+ <span className="truncate">
74
+ {modelLabel(models, model, label) || "Model"}
75
+ </span>
76
+ <ChevronDown className="h-3 w-3 shrink-0" />
77
+ </button>
78
+ <PickerSheet
79
+ open={open}
80
+ onOpenChange={(o) => {
81
+ onOpenChange(o);
82
+ }}
83
+ title="Model"
84
+ onKeyDown={onKeyDown}
85
+ onOpened={() => focusSelectedRow(root.current)}
86
+ >
87
+ <div ref={root} className="flex min-h-0 flex-1 flex-col">
88
+ <p className="text-muted-foreground bg-foreground/[0.03] mx-1.5 mt-1.5 flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-xs">
89
+ <Lock className="h-3.5 w-3.5 shrink-0" />
90
+ {agentName && `${agentName} · `}
91
+ {agentName ? "switching" : "Switching"} agent starts a new session
92
+ </p>
93
+ <SearchField
94
+ value={query}
95
+ onChange={setQuery}
96
+ placeholder="Search models"
97
+ inputRef={search}
98
+ />
99
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
100
+ <ModelList
101
+ sections={sections}
102
+ selected={current ?? null}
103
+ query={query}
104
+ agentLabel=""
105
+ onPick={(value) => {
106
+ onPick(value);
107
+ onOpenChange(false);
108
+ }}
61
109
  />
62
- <span className="flex flex-col">
63
- <span className="text-sm">{m.label}</span>
64
- {(m.disabled || m.description) && (
65
- <span className="text-muted-foreground line-clamp-2 text-xs">
66
- {m.disabled ?? m.description}
67
- </span>
68
- )}
69
- </span>
70
- </DropdownMenuItem>
71
- ))}
72
- </DropdownMenuContent>
73
- </DropdownMenu>
110
+ </div>
111
+ </div>
112
+ </PickerSheet>
113
+ </>
74
114
  );
75
115
  }
@@ -63,6 +63,7 @@ export interface ComposerProps {
63
63
  models?: ChatModel[];
64
64
  model?: string;
65
65
  modelLabel?: string;
66
+ agentName?: string;
66
67
  onSetModel?: (model: string) => void;
67
68
  access?: ChatAccess;
68
69
  onSetAccess?: (access: ChatAccess) => void;
@@ -101,6 +102,7 @@ export function ComposerBody({
101
102
  models = [],
102
103
  model = "",
103
104
  modelLabel,
105
+ agentName,
104
106
  onSetModel,
105
107
  access,
106
108
  onSetAccess,
@@ -383,6 +385,7 @@ export function ComposerBody({
383
385
  models={models}
384
386
  model={model}
385
387
  modelLabel={modelLabel}
388
+ agentName={agentName}
386
389
  onSetModel={onSetModel}
387
390
  modelOpen={modelOpen}
388
391
  onModelOpenChange={setModelOpen}
@@ -33,6 +33,7 @@ export function Toolbar({
33
33
  models,
34
34
  model,
35
35
  modelLabel,
36
+ agentName,
36
37
  onSetModel,
37
38
  modelOpen,
38
39
  onModelOpenChange,
@@ -56,6 +57,7 @@ export function Toolbar({
56
57
  models: ChatModel[];
57
58
  model: string;
58
59
  modelLabel?: string;
60
+ agentName?: string;
59
61
  onSetModel?: (model: string) => void;
60
62
  modelOpen: boolean;
61
63
  onModelOpenChange: (open: boolean) => void;
@@ -141,6 +143,7 @@ export function Toolbar({
141
143
  models={models}
142
144
  model={model}
143
145
  label={modelLabel}
146
+ agentName={agentName}
144
147
  open={modelOpen}
145
148
  onOpenChange={onModelOpenChange}
146
149
  onPick={onSetModel}
@@ -23,6 +23,7 @@ describe("usableAgent", () => {
23
23
  agent({ agent: "claude", auth: "needs-login" }),
24
24
  ],
25
25
  ollama: [],
26
+ recent: [],
26
27
  };
27
28
 
28
29
  it("keeps an agent the machine can run", () => {
@@ -1,45 +1,33 @@
1
1
  "use client";
2
2
 
3
- import { useEffect } from "react";
3
+ import { useEffect, useState } from "react";
4
4
  import { Bot } from "lucide-react";
5
5
  import type { Draft } from "@/lib/drafts";
6
6
  import { AGENT_OPTIONS, agentLabel } from "@/lib/agent-options";
7
7
  import { resolveModelForAgent } from "@/lib/model-catalog";
8
8
  import { useMachineCatalog } from "@/data/machines";
9
- import type { CatalogAgent, MachineCatalog } from "@/lib/machines/types";
10
- import { ChipHeading, ChipItem, ChipMenu } from "./Chip";
9
+ import { catalogBlocker, usableAgent } from "./catalogBlocker";
10
+ import { ChipItem, ChipMenu, ChipTrigger } from "./Chip";
11
+ import { AgentModelPicker } from "./picker/AgentModelPicker";
11
12
 
12
- // What stands between an agent and its first prompt, if anything.
13
- export function catalogBlocker(a: CatalogAgent): string | null {
14
- if (!a.installed) return "Not installed";
15
- if (a.auth === "needs-login") return a.hint ?? "Needs sign-in";
16
- return null;
17
- }
18
-
19
- /** The agent to use when the one chosen can't run on this machine. */
20
- export function usableAgent(
21
- catalog: MachineCatalog,
22
- agentType: Draft["agentType"]
23
- ): CatalogAgent | null {
24
- const have = catalog.agents.find((a) => a.agent === agentType);
25
- if (have && !catalogBlocker(have)) return null;
26
- return catalog.agents.find((a) => !catalogBlocker(a)) ?? null;
27
- }
13
+ export { catalogBlocker, usableAgent };
28
14
 
29
15
  /**
30
- * The agent and its model in one menu, grouped by agent, from what the
31
- * machine the session will run on reports. A local Ollama model picks the
32
- * agent that can run it.
16
+ * The agent and its model in one picker, from what the machine the session
17
+ * will run on reports: pick the agent, then only its models.
33
18
  */
34
19
  export function AgentModelChip({
35
20
  draft,
21
+ projectName,
36
22
  onChange,
37
23
  }: {
38
24
  draft: Draft;
25
+ projectName: string | null;
39
26
  onChange: (patch: Partial<Draft>) => void;
40
27
  }) {
41
- const { data, isPending } = useMachineCatalog(draft.hostId);
28
+ const { data, isPending } = useMachineCatalog(draft.hostId, draft.projectId);
42
29
  const catalog = data?.catalog ?? null;
30
+ const [open, setOpen] = useState(false);
43
31
 
44
32
  // The machine changed under the draft: move to an agent it has.
45
33
  useEffect(() => {
@@ -59,18 +47,33 @@ export function AgentModelChip({
59
47
 
60
48
  const current = catalog?.agents.find((a) => a.agent === draft.agentType);
61
49
  const model = current?.models.find((m) => m.value === draft.model);
62
- const label =
63
- model && draft.model !== current?.defaultModel
64
- ? `${agentLabel(draft.agentType)} · ${model.label}`
65
- : agentLabel(draft.agentType);
66
-
50
+ const label = model
51
+ ? `${agentLabel(draft.agentType)} · ${model.label}`
52
+ : agentLabel(draft.agentType);
67
53
  const blocker = current ? catalogBlocker(current) : null;
68
54
 
69
55
  return (
70
56
  <>
71
- <ChipMenu icon={Bot} name="Agent" label={label}>
72
- {!catalog &&
73
- AGENT_OPTIONS.map((o) => (
57
+ {catalog ? (
58
+ <>
59
+ <ChipTrigger
60
+ icon={Bot}
61
+ name="Agent and model"
62
+ label={label}
63
+ onClick={() => setOpen(true)}
64
+ />
65
+ <AgentModelPicker
66
+ open={open}
67
+ onOpenChange={setOpen}
68
+ catalog={catalog}
69
+ value={{ agent: draft.agentType, model: draft.model }}
70
+ projectName={projectName}
71
+ onPick={(agentType, m) => onChange({ agentType, model: m })}
72
+ />
73
+ </>
74
+ ) : (
75
+ <ChipMenu icon={Bot} name="Agent" label={label}>
76
+ {AGENT_OPTIONS.map((o) => (
74
77
  <ChipItem
75
78
  key={o.value}
76
79
  checked={o.value === draft.agentType}
@@ -89,68 +92,8 @@ export function AgentModelChip({
89
92
  {o.label}
90
93
  </ChipItem>
91
94
  ))}
92
- {catalog?.agents.map((a, i) => {
93
- const blocked = catalogBlocker(a);
94
- return (
95
- <div key={a.agent}>
96
- <ChipHeading first={i === 0}>
97
- {a.label}
98
- {blocked ? ` · ${blocked}` : ""}
99
- </ChipHeading>
100
- {a.models.map((m) => (
101
- <ChipItem
102
- key={m.value}
103
- checked={
104
- a.agent === draft.agentType && m.value === draft.model
105
- }
106
- disabled={!!blocked}
107
- hint={m.description}
108
- onSelect={() =>
109
- onChange({ agentType: a.agent, model: m.value })
110
- }
111
- >
112
- {m.label}
113
- </ChipItem>
114
- ))}
115
- {a.modelsError && (
116
- <p className="text-muted-foreground px-2 py-1 text-xs">
117
- Couldn&apos;t list its models: {a.modelsError}
118
- </p>
119
- )}
120
- </div>
121
- );
122
- })}
123
- {catalog && catalog.ollama.length > 0 && (
124
- <div>
125
- <ChipHeading>Ollama (local models)</ChipHeading>
126
- {catalog.ollama.map((o) => (
127
- <ChipItem
128
- key={o.name}
129
- checked={
130
- !!o.runsWith &&
131
- o.runsWith.agent === draft.agentType &&
132
- o.runsWith.model === draft.model
133
- }
134
- disabled={!o.runsWith}
135
- hint={
136
- o.runsWith
137
- ? `Runs in ${agentLabel(o.runsWith.agent)}`
138
- : "No agent here is set up to run it"
139
- }
140
- onSelect={() =>
141
- o.runsWith &&
142
- onChange({
143
- agentType: o.runsWith.agent,
144
- model: o.runsWith.model,
145
- })
146
- }
147
- >
148
- {o.name}
149
- </ChipItem>
150
- ))}
151
- </div>
152
- )}
153
- </ChipMenu>
95
+ </ChipMenu>
96
+ )}
154
97
  {blocker && (
155
98
  <p className="w-full text-xs text-amber-600 dark:text-amber-400">
156
99
  {blocker}
@@ -14,6 +14,33 @@ import { cn } from "@/lib/utils";
14
14
  const chipClass =
15
15
  "border-border/70 bg-card text-muted-foreground hover:text-foreground hover:bg-foreground/[0.04] flex h-11 max-w-full min-w-0 items-center gap-1.5 rounded-lg border px-2.5 text-xs md:h-8 disabled:pointer-events-none disabled:opacity-60";
16
16
 
17
+ // The button of a choice whose picker is its own dialog.
18
+ export function ChipTrigger({
19
+ icon: Icon,
20
+ label,
21
+ name,
22
+ onClick,
23
+ }: {
24
+ icon: LucideIcon;
25
+ label: string;
26
+ name: string;
27
+ onClick: () => void;
28
+ }) {
29
+ return (
30
+ <button
31
+ type="button"
32
+ aria-label={`${name}: ${label}`}
33
+ aria-haspopup="dialog"
34
+ onClick={onClick}
35
+ className={chipClass}
36
+ >
37
+ <Icon className="h-3.5 w-3.5 shrink-0" />
38
+ <span className="truncate">{label}</span>
39
+ <ChevronDown className="h-3 w-3 shrink-0" />
40
+ </button>
41
+ );
42
+ }
43
+
17
44
  // One of a draft's choices: what it is now, and a menu to change it.
18
45
  export function ChipMenu({
19
46
  icon: Icon,
@@ -159,7 +159,11 @@ export function DraftChips({
159
159
  )}
160
160
 
161
161
  {!draft.openPr && !draft.agentRole && (
162
- <AgentModelChip draft={draft} onChange={onChange} />
162
+ <AgentModelChip
163
+ draft={draft}
164
+ projectName={project?.name ?? null}
165
+ onChange={onChange}
166
+ />
163
167
  )}
164
168
 
165
169
  {!draft.openPr && local && (
@@ -8,8 +8,6 @@ import { Button } from "@/components/ui/button";
8
8
  import type { ChatFile, ChatImage } from "@/lib/chat/events";
9
9
  import type { Project } from "@/lib/db";
10
10
  import { draftComposerKey, type Draft } from "@/lib/drafts";
11
- import { discoversModels } from "@/lib/model-catalog";
12
- import { useDiscoveredModels } from "@/data/agents";
13
11
  import { useHostsQuery } from "@/data/hosts";
14
12
  import { useGitCheck } from "@/data/git/queries";
15
13
  import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
@@ -27,8 +25,6 @@ import { useStartAnalystBeside } from "@/components/Analyst/pair";
27
25
  import { DraftChips } from "./DraftChips";
28
26
  import { DraftHeading } from "./DraftHeading";
29
27
  import { ModelUnavailableDialog } from "./ModelUnavailableDialog";
30
- import { useMachineCatalog } from "@/data/machines";
31
- import { draftModels } from "./draftModels";
32
28
 
33
29
  // A new session before its first send: choices on top, a composer below.
34
30
  // Sending makes the session (and its worktree) and turns this tab into it.
@@ -52,14 +48,6 @@ export function DraftPanel({
52
48
  const { data: git } = useGitCheck(
53
49
  project && draft?.hostId === "local" ? project.working_directory : ""
54
50
  );
55
- const discovers = !!draft && discoversModels(draft.agentType);
56
- const found = useDiscoveredModels(
57
- draft?.agentType ?? "",
58
- draft?.projectId ?? null,
59
- discovers
60
- );
61
- // On another machine the models are the ones it reports, not this one's.
62
- const there = useMachineCatalog(draft?.hostId ?? "local");
63
51
  const launch = useLaunchSession();
64
52
  const startAnalyst = useStartAnalystBeside();
65
53
  const { workspace } = useSelectedWorkspace();
@@ -262,15 +250,6 @@ export function DraftPanel({
262
250
  }
263
251
  onSend={(text, images, files) => void send(text, images, files)}
264
252
  onStop={() => {}}
265
- models={
266
- (draft.hostId !== "local" &&
267
- there.data?.catalog?.agents.find(
268
- (a) => a.agent === draft.agentType
269
- )?.models) ||
270
- draftModels(draft.agentType, found)
271
- }
272
- model={draft.model}
273
- onSetModel={(model) => draftsActions.update(draft.id, { model })}
274
253
  access={draft.openPr ? undefined : draft.access}
275
254
  onSetAccess={(access) => draftsActions.update(draft.id, { access })}
276
255
  prefill={prefill}
@@ -0,0 +1,19 @@
1
+ import type { Draft } from "@/lib/drafts";
2
+ import type { CatalogAgent, MachineCatalog } from "@/lib/machines/types";
3
+
4
+ // What stands between an agent and its first prompt, if anything.
5
+ export function catalogBlocker(a: CatalogAgent): string | null {
6
+ if (!a.installed) return "Not installed";
7
+ if (a.auth === "needs-login") return a.hint ?? "Needs sign-in";
8
+ return null;
9
+ }
10
+
11
+ /** The agent to use when the one chosen can't run on this machine. */
12
+ export function usableAgent(
13
+ catalog: MachineCatalog,
14
+ agentType: Draft["agentType"]
15
+ ): CatalogAgent | null {
16
+ const have = catalog.agents.find((a) => a.agent === agentType);
17
+ if (have && !catalogBlocker(have)) return null;
18
+ return catalog.agents.find((a) => !catalogBlocker(a)) ?? null;
19
+ }