@saadnvd1/agent-os 0.3.744 → 0.3.749

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 (49) hide show
  1. package/README.md +1 -1
  2. package/app/api/models/choices/route.ts +7 -3
  3. package/app/api/sessions/[id]/model/route.ts +2 -23
  4. package/components/Analyst/StartAnalystSheet.tsx +7 -2
  5. package/components/Analyst/useAnalystModels.ts +19 -0
  6. package/components/Approvals/NoApprovalsBadge.tsx +22 -9
  7. package/components/Away/AwayToggle.tsx +5 -5
  8. package/components/Chat/ChatPanel.tsx +10 -2
  9. package/components/Chat/Messages.test.ts +33 -0
  10. package/components/Chat/Messages.tsx +15 -0
  11. package/components/Chat/ModelPicker.tsx +3 -2
  12. package/components/Chat/idle.ts +9 -0
  13. package/components/Draft/AnalystModelChip.tsx +58 -0
  14. package/components/Draft/DraftChips.tsx +8 -0
  15. package/components/Draft/DraftPanel.tsx +7 -1
  16. package/components/Draft/RoleChips.tsx +12 -3
  17. package/components/Models/AgentRoleCard.tsx +9 -2
  18. package/components/Models/RoleModelSelect.tsx +23 -8
  19. package/components/SessionList/MachineSwitcher.tsx +1 -1
  20. package/components/SessionList/ProjectFilter.tsx +1 -1
  21. package/components/SessionList/SidebarHeader.tsx +3 -3
  22. package/components/SessionList/SidebarSearch.tsx +1 -1
  23. package/components/SessionList/index.tsx +1 -1
  24. package/components/Settings/NoApprovalsSection.test.ts +5 -2
  25. package/lib/chat/demo.ts +23 -2
  26. package/lib/chat/drivers/demo-script.ts +30 -4
  27. package/lib/chat/drivers/opencode-config.ts +5 -0
  28. package/lib/chat/drivers/opencode-mapper.ts +104 -17
  29. package/lib/chat/drivers/opencode.ts +19 -1
  30. package/lib/chat/drivers/turn-outcome.ts +22 -1
  31. package/lib/chat/events.ts +12 -1
  32. package/lib/chat/group.ts +11 -2
  33. package/lib/chat/progress.ts +4 -0
  34. package/lib/chat/role-models.ts +46 -0
  35. package/lib/chat/runner-watch.ts +1 -1
  36. package/lib/chat/settings.ts +7 -4
  37. package/lib/chat/socket.ts +3 -2
  38. package/lib/chat/turn-end.ts +16 -0
  39. package/lib/drafts.ts +2 -0
  40. package/lib/models/agent-binding.ts +23 -2
  41. package/lib/models/catalog.ts +43 -1
  42. package/lib/models/providers.ts +12 -0
  43. package/lib/models/resolve.ts +3 -7
  44. package/lib/models/session-model.ts +70 -4
  45. package/lib/models/tool-use.ts +78 -0
  46. package/lib/models/usable.ts +45 -0
  47. package/lib/updates/check.ts +32 -0
  48. package/lib/updates/view.ts +2 -1
  49. package/package.json +1 -1
package/README.md CHANGED
@@ -19,7 +19,7 @@ and check on all of it from your phone.
19
19
  - **Other machines in the same list.** Link another machine that runs AgentOS and start or move work there. [More](docs/reference/machines.md)
20
20
  - **Built for your phone.** The whole app works on a phone; pair it with a QR code. [More](docs/guides/phone.md)
21
21
  - **Phone to orchestrator.** A paired phone can send a shake report or a shared page or file to a workspace's orchestrator (`POST /api/orchestrators/:workspaceId/intake`, saved under `intake/` beside the database, shared content marked untrusted), and fetch names to improve dictation (`GET /api/voice/vocab?workspace=`). [More](docs/guides/phone.md)
22
- - **An analyst beside your coding agent.** Pair a chat with a second one that reviews each turn, explains it in plain English, flags issues and drafts the next prompt, which you send, edit or dismiss. Side by side on a desktop, a switch on a phone. [More](docs/decisions/2026-10-21-analyst-sessions.md)
22
+ - **An analyst beside your coding agent.** Pair a chat with a second one that reviews each turn, explains it in plain English, flags issues and drafts the next prompt, which you send, edit or dismiss. It runs on Claude unless you pick another model, local ones included. Side by side on a desktop, a switch on a phone. [More](docs/decisions/2026-10-21-analyst-sessions.md)
23
23
  - **Agents that talk to each other.** Sessions message each other with `aos`, and you see every conversation in Messages. [More](docs/guides/agent-network.md)
24
24
  - **Chat or terminal, same conversation.** Streaming replies, steps and diffs, plan mode, slash commands, `@file`, undo. [More](docs/guides/chat.md)
25
25
  - **Bookmarks.** Star any message, tool call or turn, then find it again from the chat or across every chat, with a search. [More](docs/guides/chat.md)
@@ -1,9 +1,13 @@
1
1
  import { NextResponse } from "next/server";
2
- import { modelCatalog } from "@/lib/models/catalog";
2
+ import { modelCatalog, unconfiguredChoices } from "@/lib/models/catalog";
3
3
 
4
4
  // GET /api/models/choices - every model a picker offers, from the server:
5
5
  // the enabled providers' models and the ones Pi reaches, each with its
6
- // locality (local or cloud) and the last availability check.
6
+ // locality (local or cloud), the last availability check and whether it can
7
+ // call tools. Models of a provider that is off, or half set up, are listed
8
+ // with `unusable` saying why, so a picker can grey them rather than hide them.
7
9
  export async function GET() {
8
- return NextResponse.json({ choices: modelCatalog() });
10
+ return NextResponse.json({
11
+ choices: [...modelCatalog(), ...unconfiguredChoices()],
12
+ });
9
13
  }
@@ -1,10 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { db, type Session } from "@/lib/db";
3
- import { setChatModel } from "@/lib/chat/settings";
4
- import { providerFor, sessionRole } from "@/lib/models/resolve";
5
- import { switchSessionModel } from "@/lib/models/session-model";
6
- import { PI } from "@/lib/models/providers";
7
- import { formatModelRef, parseModelRef } from "@/lib/models/roles";
3
+ import { switchSessionModelRef } from "@/lib/models/session-model";
8
4
 
9
5
  // POST /api/sessions/:id/model {ref} - move a running session to another
10
6
  // model ("provider/model", or "pi/<provider>/<model>" for a Pi session),
@@ -23,24 +19,7 @@ export async function POST(
23
19
  if (typeof ref !== "string" || !ref.trim())
24
20
  return NextResponse.json({ error: "ref is required" }, { status: 400 });
25
21
  try {
26
- const parsed = parseModelRef(ref);
27
- if (session.agent_type === "pi") {
28
- if (parsed.provider !== PI)
29
- throw new Error(
30
- "A Pi session runs on a Pi model (pi/<provider>/<model>)"
31
- );
32
- await setChatModel(id, parsed.model);
33
- } else {
34
- if (parsed.provider === PI)
35
- throw new Error("Only a Pi session can move to a Pi model");
36
- await switchSessionModel(id, {
37
- role: sessionRole(session),
38
- ref: formatModelRef(parsed),
39
- provider: providerFor(parsed.provider),
40
- model: parsed.model,
41
- source: "task",
42
- });
43
- }
22
+ await switchSessionModelRef(session, ref);
44
23
  return NextResponse.json({ ok: true });
45
24
  } catch (error) {
46
25
  const message = error instanceof Error ? error.message : String(error);
@@ -13,8 +13,10 @@ import {
13
13
  } from "@/components/ui/dialog";
14
14
  import { Switch } from "@/components/ui/switch";
15
15
  import { RoleModelSelect } from "@/components/Models/RoleModelSelect";
16
+ import { useSessionsQuery } from "@/data/sessions/queries";
16
17
  import { analystUi, analystUiActions } from "@/stores/analystUi";
17
18
  import { useStartAnalystBeside } from "./pair";
19
+ import { useAnalystModels } from "./useAnalystModels";
18
20
 
19
21
  // Pairs an analyst with a chat: it reviews each turn the coding agent
20
22
  // finishes and recommends the next prompt, which the user sends or not.
@@ -46,6 +48,9 @@ function StartAnalystForm({
46
48
  const [model, setModel] = useState<string | null>(null);
47
49
  const [reviewLast, setReviewLast] = useState(false);
48
50
  const start = useStartAnalystBeside();
51
+ const { data } = useSessionsQuery();
52
+ const coder = data?.sessions.find((s) => s.id === sessionId);
53
+ const models = useAnalystModels(coder?.project_id);
49
54
 
50
55
  return (
51
56
  <DialogContent className="sm:max-w-[460px]">
@@ -68,8 +73,8 @@ function StartAnalystForm({
68
73
  <RoleModelSelect
69
74
  id="analyst-model"
70
75
  value={model}
71
- inheritLabel="The analyst role's model"
72
- claudeOnly
76
+ inheritLabel={models.defaultLabel}
77
+ blockedBy={models.blockedBy}
73
78
  onChange={setModel}
74
79
  />
75
80
  </div>
@@ -0,0 +1,19 @@
1
+ import { useAgentRolesQuery, useModelChoices } from "@/data/models";
2
+ import { analystBlock, defaultAnalystLabel } from "@/lib/models/usable";
3
+
4
+ // What an analyst's model pickers need, all from the server: the list, the
5
+ // reason a model can't be picked, and the label of the default (the model
6
+ // the analyst role resolves to in the project the analyst starts in).
7
+ export function useAnalystModels(projectId?: string | null) {
8
+ const { data: choices = [] } = useModelChoices();
9
+ const roles = useAgentRolesQuery(
10
+ projectId ? "project" : "settings",
11
+ projectId
12
+ );
13
+ const bound = roles.data?.roles.find((r) => r.id === "analyst")?.binding;
14
+ return {
15
+ choices,
16
+ defaultLabel: defaultAnalystLabel(bound?.ref, choices),
17
+ blockedBy: analystBlock,
18
+ };
19
+ }
@@ -3,20 +3,33 @@
3
3
  import { ShieldOff } from "lucide-react";
4
4
  import { useApprovalsModeQuery } from "@/data/approvals";
5
5
  import { settingsUiActions } from "@/stores/settingsUi";
6
+ import {
7
+ Tooltip,
8
+ TooltipContent,
9
+ TooltipTrigger,
10
+ } from "@/components/ui/tooltip";
6
11
 
7
12
  // On screen for as long as No approvals mode is on; opens its Settings.
13
+ // Shrinks to the icon when the sidebar header is narrow.
8
14
  export function NoApprovalsBadge() {
9
15
  const { data } = useApprovalsModeQuery();
10
16
  if (!data?.on) return null;
11
17
  return (
12
- <button
13
- type="button"
14
- onClick={() => settingsUiActions.open("approvals")}
15
- title="No approvals mode is on: nothing waits for a person. Open settings."
16
- className="flex h-8 shrink-0 items-center gap-1 rounded-md bg-amber-500/20 px-2 text-xs font-medium text-amber-500 ring-1 ring-amber-500/50"
17
- >
18
- <ShieldOff className="h-3.5 w-3.5" />
19
- No approvals
20
- </button>
18
+ <Tooltip>
19
+ <TooltipTrigger asChild>
20
+ <button
21
+ type="button"
22
+ onClick={() => settingsUiActions.open("approvals")}
23
+ aria-label="No approvals mode is on. Open settings."
24
+ className="flex h-11 w-11 shrink-0 items-center justify-center gap-1 rounded-md bg-amber-500/20 text-xs font-medium text-amber-500 ring-1 ring-amber-500/50 md:h-8 md:w-8 @[22rem]:w-auto @[22rem]:px-2 [@media(hover:none)]:md:h-11"
25
+ >
26
+ <ShieldOff className="h-3.5 w-3.5" />
27
+ <span className="hidden @[22rem]:inline">No approvals</span>
28
+ </button>
29
+ </TooltipTrigger>
30
+ <TooltipContent>
31
+ No approvals mode is on: nothing waits for a person. Open settings.
32
+ </TooltipContent>
33
+ </Tooltip>
21
34
  );
22
35
  }
@@ -39,16 +39,16 @@ export function AwayToggle() {
39
39
  return (
40
40
  <ADropdownMenu
41
41
  minWidth="220px"
42
+ tooltip={
43
+ away
44
+ ? `Away ${until}`
45
+ : "Away: let the orchestrators follow work through"
46
+ }
42
47
  trigger={
43
48
  <Button
44
49
  variant="ghost"
45
50
  size="icon-sm"
46
51
  aria-label={away ? `Away ${until}` : "Away"}
47
- title={
48
- away
49
- ? `Away ${until}`
50
- : "Away: let the orchestrators follow work through"
51
- }
52
52
  disabled={setAway.isPending}
53
53
  className={cn(
54
54
  "relative h-11 w-11 md:h-8 md:w-8 [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11",
@@ -56,7 +56,8 @@ import { McpServers } from "./McpServers";
56
56
  import { ChatSessionId } from "./session";
57
57
  import { ArtifactCard } from "./Artifact";
58
58
  import { PlanCard } from "./PlanCard";
59
- import { ChatIdle } from "./idle";
59
+ import { ChatIdle, RetryTurn } from "./idle";
60
+ import { RETRY_TEXT } from "@/lib/chat/turn-end";
60
61
  import { ContextMeter } from "./ContextMeter";
61
62
  import { SetupCard } from "./SetupCard";
62
63
  import { UserTurn } from "./EventRow";
@@ -631,6 +632,11 @@ export function ChatPanel({
631
632
  // Another conversation starts without the last one's find.
632
633
  useEffect(() => closeFind, [sessionId, closeFind]);
633
634
  const idle = !running && connected;
635
+ const lastEndId = items.findLast((i) => i.kind === "turn_end")?.id ?? null;
636
+ const retryTurn = useMemo(
637
+ () => ({ id: lastEndId, retry: () => send(RETRY_TEXT) }),
638
+ [lastEndId, send]
639
+ );
634
640
  const actions: ItemActions = useMemo(
635
641
  () => ({
636
642
  respond,
@@ -691,7 +697,9 @@ export function ChatPanel({
691
697
  </div>
692
698
  <ChatSessionId.Provider value={sessionId}>
693
699
  <ChatIdle.Provider value={idle}>
694
- <TimelineRows blocks={blocks} actions={actions} />
700
+ <RetryTurn.Provider value={retryTurn}>
701
+ <TimelineRows blocks={blocks} actions={actions} />
702
+ </RetryTurn.Provider>
695
703
  </ChatIdle.Provider>
696
704
  </ChatSessionId.Provider>
697
705
  {pending.map((p) => (
@@ -2,6 +2,7 @@ import { createElement } from "react";
2
2
  import { renderToStaticMarkup } from "react-dom/server";
3
3
  import { describe, expect, it, vi } from "vitest";
4
4
  import { AssistantMessage, TurnEnd, UserMessage } from "./Messages";
5
+ import { ChatIdle, RetryTurn } from "./idle";
5
6
 
6
7
  const item = {
7
8
  id: "a1",
@@ -131,3 +132,35 @@ describe("TurnEnd steps", () => {
131
132
  expect(html).toContain("Stopped · 4 of 5 steps done, 1 failed, 1 not run");
132
133
  });
133
134
  });
135
+
136
+ describe("TurnEnd retry", () => {
137
+ const end = (reason: string, id = "e") =>
138
+ ({
139
+ id,
140
+ kind: "turn_end",
141
+ ended: { reason, notice: "It stalled." },
142
+ }) as never;
143
+ const html = (item: never, retry: { id: string | null }, idle = true) =>
144
+ renderToStaticMarkup(
145
+ createElement(
146
+ ChatIdle.Provider,
147
+ { value: idle },
148
+ createElement(
149
+ RetryTurn.Provider,
150
+ { value: { ...retry, retry: () => {} } },
151
+ createElement(TurnEnd, { item })
152
+ )
153
+ )
154
+ );
155
+
156
+ it("offers Retry on the latest stalled end while idle", () => {
157
+ expect(html(end("stalled"), { id: "e" })).toContain(">Retry<");
158
+ expect(html(end("compaction_empty"), { id: "e" })).toContain(">Retry<");
159
+ });
160
+
161
+ it("does not on an older end, a running chat, or an ending retry can't fix", () => {
162
+ expect(html(end("stalled"), { id: "later" })).not.toContain("Retry");
163
+ expect(html(end("stalled"), { id: "e" }, false)).not.toContain("Retry");
164
+ expect(html(end("length"), { id: "e" })).not.toContain("Retry");
165
+ });
166
+ });
@@ -2,6 +2,8 @@
2
2
 
3
3
  import type { StepCounts } from "@/lib/chat/tool-outcome";
4
4
  import { memo } from "react";
5
+ import { retryableEnd } from "@/lib/chat/turn-end";
6
+ import { useChatIdle, useRetryTurn } from "./idle";
5
7
  import { useSnapshot } from "valtio";
6
8
  import {
7
9
  Check,
@@ -398,6 +400,10 @@ export function TurnEnd({
398
400
  }) {
399
401
  const secs = item.durationMs ? Math.round(item.durationMs / 1000) : null;
400
402
  const early = !item.interrupted && item.ended;
403
+ const idle = useChatIdle();
404
+ const retryTurn = useRetryTurn();
405
+ const canRetry =
406
+ !!early && idle && retryTurn.id === item.id && retryableEnd(early.reason);
401
407
  const label = item.interrupted
402
408
  ? "Stopped"
403
409
  : early
@@ -420,6 +426,15 @@ export function TurnEnd({
420
426
  finish reason: {item.finishReason}
421
427
  </span>
422
428
  )}
429
+ {canRetry && (
430
+ <button
431
+ type="button"
432
+ onClick={retryTurn.retry}
433
+ className="hover:bg-foreground/[0.06] mt-2 block min-h-11 rounded-md border border-current/30 px-3 text-sm font-medium md:min-h-8"
434
+ >
435
+ Retry
436
+ </button>
437
+ )}
423
438
  </div>
424
439
  )}
425
440
  <div className="group text-muted-foreground/60 flex items-center gap-3 font-mono text-[11px]">
@@ -53,6 +53,7 @@ export function ModelPicker({
53
53
  <DropdownMenuItem
54
54
  key={m.value}
55
55
  onSelect={() => onPick(m.value)}
56
+ disabled={!!m.disabled}
56
57
  className="flex min-h-11 items-start gap-2 md:min-h-9"
57
58
  >
58
59
  <Check
@@ -60,9 +61,9 @@ export function ModelPicker({
60
61
  />
61
62
  <span className="flex flex-col">
62
63
  <span className="text-sm">{m.label}</span>
63
- {m.description && (
64
+ {(m.disabled || m.description) && (
64
65
  <span className="text-muted-foreground line-clamp-2 text-xs">
65
- {m.description}
66
+ {m.disabled ?? m.description}
66
67
  </span>
67
68
  )}
68
69
  </span>
@@ -7,3 +7,12 @@ import { createContext, useContext } from "react";
7
7
  // re-renders those buttons and not every row of a long conversation.
8
8
  export const ChatIdle = createContext(true);
9
9
  export const useChatIdle = () => useContext(ChatIdle);
10
+
11
+ // The latest turn's end and what retrying it does: only that end offers
12
+ // Retry, and only while the chat is idle. A context so the one turn ending
13
+ // doesn't re-render every row.
14
+ export const RetryTurn = createContext<{
15
+ id: string | null;
16
+ retry: () => void;
17
+ }>({ id: null, retry: () => {} });
18
+ export const useRetryTurn = () => useContext(RetryTurn);
@@ -0,0 +1,58 @@
1
+ "use client";
2
+
3
+ import { Cpu } from "lucide-react";
4
+ import {
5
+ AvailabilityDot,
6
+ LocalityBadge,
7
+ } from "@/components/Models/ModelBadges";
8
+ import { useAnalystModels } from "@/components/Analyst/useAnalystModels";
9
+ import type { Draft } from "@/lib/drafts";
10
+ import { ChipItem, ChipMenu } from "./Chip";
11
+
12
+ // The model of the analyst a new chat starts with: the analyst role's
13
+ // default unless one is picked. The list is the server's; a model that
14
+ // can't serve an analyst is greyed with the reason.
15
+ export function AnalystModelChip({
16
+ draft,
17
+ projectId,
18
+ onChange,
19
+ }: {
20
+ draft: Draft;
21
+ projectId: string | null;
22
+ onChange: (patch: Partial<Draft>) => void;
23
+ }) {
24
+ const { choices, defaultLabel, blockedBy } = useAnalystModels(projectId);
25
+ const picked = choices.find((c) => c.ref === draft.analystModel);
26
+ return (
27
+ <ChipMenu
28
+ icon={Cpu}
29
+ name="Analyst model"
30
+ label={picked?.label ?? draft.analystModel ?? defaultLabel}
31
+ >
32
+ <ChipItem
33
+ checked={!draft.analystModel}
34
+ onSelect={() => onChange({ analystModel: null })}
35
+ >
36
+ {defaultLabel}
37
+ </ChipItem>
38
+ {choices.map((c) => {
39
+ const why = c.unusable ?? blockedBy(c);
40
+ return (
41
+ <ChipItem
42
+ key={c.ref}
43
+ checked={c.ref === draft.analystModel}
44
+ onSelect={() => onChange({ analystModel: c.ref })}
45
+ disabled={!!why}
46
+ hint={why ?? c.group}
47
+ >
48
+ <span className="flex items-center gap-1.5">
49
+ <AvailabilityDot availability={c.availability} />
50
+ {c.label}
51
+ <LocalityBadge locality={c.locality} />
52
+ </span>
53
+ </ChipItem>
54
+ );
55
+ })}
56
+ </ChipMenu>
57
+ );
58
+ }
@@ -20,6 +20,7 @@ import { modelChoices, useModelsQuery } from "@/data/models";
20
20
  import { useBrowserMachine } from "@/data/machines";
21
21
  import { machineLabel } from "@/lib/machines/label";
22
22
  import { RoleChips } from "./RoleChips";
23
+ import { AnalystModelChip } from "./AnalystModelChip";
23
24
  import { AgentModelChip } from "./AgentModelChip";
24
25
  import { ChipHeading, ChipItem, ChipMenu, ChipToggle } from "./Chip";
25
26
  import {
@@ -232,6 +233,13 @@ export function DraftChips({
232
233
  onChange={(withAnalyst) => onChange({ withAnalyst })}
233
234
  />
234
235
  )}
236
+ {project && !draft.openPr && local && draft.withAnalyst && (
237
+ <AnalystModelChip
238
+ draft={draft}
239
+ projectId={project.id}
240
+ onChange={onChange}
241
+ />
242
+ )}
235
243
  {project && draft.openPr && local && (
236
244
  <ChipMenu
237
245
  icon={Cpu}
@@ -179,7 +179,13 @@ export function DraftPanel({
179
179
  );
180
180
  if (draft.withAnalyst && project && draft.hostId === "local") {
181
181
  if (session.view === "chat")
182
- void startAnalyst.run({ coderSessionId: session.id }, paneId);
182
+ void startAnalyst.run(
183
+ {
184
+ coderSessionId: session.id,
185
+ analystModel: draft.analystModel ?? undefined,
186
+ },
187
+ paneId
188
+ );
183
189
  else toast("An analyst reviews chats: this session runs in a terminal");
184
190
  }
185
191
  } catch (error) {
@@ -7,6 +7,7 @@ import {
7
7
  } from "@/components/Models/ModelBadges";
8
8
  import { useAgentRolesQuery, useModelChoices } from "@/data/models";
9
9
  import type { Draft } from "@/lib/drafts";
10
+ import { analystBlock, defaultAnalystLabel } from "@/lib/models/usable";
10
11
  import {
11
12
  AGENT_ROLES,
12
13
  AGENT_ROLE_DEFS,
@@ -34,6 +35,9 @@ export function RoleChips({
34
35
  const current = override ?? bound?.choice ?? null;
35
36
  const modelName = (ref: string | undefined | null) =>
36
37
  ref ? ref.split("/").slice(1).join("/") : "…";
38
+ const analyst = role === "analyst";
39
+ const whyNot = (c: (typeof choices)[number]) =>
40
+ c.unusable ?? (analyst ? analystBlock(c) : null);
37
41
  return (
38
42
  <>
39
43
  <ChipMenu
@@ -71,7 +75,9 @@ export function RoleChips({
71
75
  label={
72
76
  draft.roleModel
73
77
  ? modelName(draft.roleModel)
74
- : `Role model: ${modelName(bound?.binding?.ref)}`
78
+ : analyst
79
+ ? defaultAnalystLabel(bound?.binding?.ref, choices)
80
+ : `Role model: ${modelName(bound?.binding?.ref)}`
75
81
  }
76
82
  >
77
83
  <ChipItem
@@ -79,14 +85,17 @@ export function RoleChips({
79
85
  onSelect={() => onChange({ roleModel: null })}
80
86
  hint={bound?.binding ? modelName(bound.binding.ref) : undefined}
81
87
  >
82
- Role model
88
+ {analyst
89
+ ? defaultAnalystLabel(bound?.binding?.ref, choices)
90
+ : "Role model"}
83
91
  </ChipItem>
84
92
  {choices.map((c) => (
85
93
  <ChipItem
86
94
  key={c.ref}
87
95
  checked={c.ref === draft.roleModel}
88
96
  onSelect={() => onChange({ roleModel: c.ref })}
89
- hint={c.group}
97
+ disabled={!!whyNot(c)}
98
+ hint={whyNot(c) ?? c.group}
90
99
  >
91
100
  <span className="flex items-center gap-1.5">
92
101
  <AvailabilityDot availability={c.availability} />
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { AlertTriangle } from "lucide-react";
4
4
  import type { AgentRoleView } from "@/lib/models/agent-roles-view";
5
+ import { analystBlock, defaultAnalystLabel } from "@/lib/models/usable";
5
6
  import { cn } from "@/lib/utils";
6
7
  import { RoleModelSelect } from "./RoleModelSelect";
7
8
 
@@ -33,7 +34,11 @@ export function AgentRoleCard({
33
34
  onFallback: (ref: string | null) => void;
34
35
  }) {
35
36
  const down = role.choice?.availability.state === "down";
36
- const label = `Default (${role.default.split("/").slice(1).join("/")})`;
37
+ const analyst = role.id === "analyst";
38
+ const label = analyst
39
+ ? defaultAnalystLabel(role.default, [])
40
+ : `Default (${role.default.split("/").slice(1).join("/")})`;
41
+ const blockedBy = analyst ? analystBlock : undefined;
37
42
  const inheritLabel =
38
43
  scope === "settings"
39
44
  ? role.modelRole === "advise"
@@ -66,6 +71,7 @@ export function AgentRoleCard({
66
71
  ? `${inheritLabel} · ${role.binding.ref.split("/").slice(1).join("/")}`
67
72
  : inheritLabel
68
73
  }
74
+ blockedBy={blockedBy}
69
75
  disabled={pending}
70
76
  onChange={onChange}
71
77
  />
@@ -102,7 +108,8 @@ export function AgentRoleCard({
102
108
  id={`agent-role-fallback-${role.id}`}
103
109
  value={role.fallback}
104
110
  inheritLabel="None (don't fall back)"
105
- claudeOnly
111
+ claudeOnly={!analyst}
112
+ blockedBy={blockedBy}
106
113
  disabled={pending}
107
114
  onChange={onFallback}
108
115
  />
@@ -15,25 +15,35 @@ import { AvailabilityDot, LocalityBadge } from "./ModelBadges";
15
15
 
16
16
  const INHERIT = "__inherit";
17
17
 
18
- function Row({ choice }: { choice: ModelChoice }) {
18
+ function Row({ choice, why }: { choice: ModelChoice; why?: string | null }) {
19
19
  return (
20
- <span className="flex min-w-0 flex-1 items-center gap-2">
21
- <AvailabilityDot availability={choice.availability} />
22
- <span className="truncate">{choice.label}</span>
23
- <LocalityBadge locality={choice.locality} />
20
+ <span className="flex min-w-0 flex-1 flex-col">
21
+ <span className="flex min-w-0 items-center gap-2">
22
+ <AvailabilityDot availability={choice.availability} />
23
+ <span className="truncate">{choice.label}</span>
24
+ <LocalityBadge locality={choice.locality} />
25
+ </span>
26
+ {why && (
27
+ <span className="text-muted-foreground text-xs whitespace-normal">
28
+ {why}
29
+ </span>
30
+ )}
24
31
  </span>
25
32
  );
26
33
  }
27
34
 
28
35
  // A model picker over the server's list: grouped by provider, each model
29
36
  // labelled Local or Cloud with whether its server answers. `claudeOnly`
30
- // leaves out the models that run through Pi.
37
+ // leaves out the models that run through Pi. `blockedBy` greys a model with
38
+ // the reason it can't be picked instead of hiding it; a model whose provider
39
+ // isn't set up is always greyed.
31
40
  export function RoleModelSelect({
32
41
  id,
33
42
  value,
34
43
  inheritLabel,
35
44
  claudeOnly,
36
45
  gateOnly,
46
+ blockedBy,
37
47
  disabled,
38
48
  onChange,
39
49
  }: {
@@ -43,11 +53,16 @@ export function RoleModelSelect({
43
53
  claudeOnly?: boolean;
44
54
  // Only models that can serve a merge gate are selectable.
45
55
  gateOnly?: boolean;
56
+ blockedBy?: (c: ModelChoice) => string | null;
46
57
  disabled?: boolean;
47
58
  onChange: (ref: string | null) => void;
48
59
  }) {
49
60
  const { data } = useModelChoices();
50
61
  const choices = (data ?? []).filter((c) => !claudeOnly || c.agent !== "pi");
62
+ const whyNot = (c: ModelChoice) =>
63
+ c.unusable ??
64
+ blockedBy?.(c) ??
65
+ (gateOnly && !c.gateCapable ? "Can't serve a merge gate" : null);
51
66
  const groups = [...new Set(choices.map((c) => c.group))];
52
67
  const current = value ? choices.find((c) => c.ref === value) : undefined;
53
68
  return (
@@ -73,10 +88,10 @@ export function RoleModelSelect({
73
88
  <SelectItem
74
89
  key={c.ref}
75
90
  value={c.ref}
76
- disabled={gateOnly && !c.gateCapable}
91
+ disabled={!!whyNot(c)}
77
92
  className="min-h-11 md:min-h-0"
78
93
  >
79
- <Row choice={c} />
94
+ <Row choice={c} why={whyNot(c)} />
80
95
  </SelectItem>
81
96
  ))}
82
97
  </SelectGroup>
@@ -32,7 +32,7 @@ export function MachineSwitcher() {
32
32
  type="button"
33
33
  aria-label={`Machine: ${current ? machineLabel(current) : "All machines"}`}
34
34
  className={cn(
35
- "border-border/70 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",
35
+ "text-muted-foreground hover:text-foreground bg-card flex min-h-11 max-w-full min-w-0 items-center gap-1.5 rounded-xl border border-transparent px-3 text-sm shadow-xs md:min-h-9 [@media(hover:none)]:md:min-h-11",
36
36
  current && "border-primary/40 bg-primary/10 text-primary"
37
37
  )}
38
38
  >
@@ -25,7 +25,7 @@ export function ProjectFilter({
25
25
  <button
26
26
  type="button"
27
27
  className={cn(
28
- "bg-card flex min-h-11 max-w-[7.5rem] shrink items-center gap-1 rounded-xl px-3 text-sm shadow-xs md:min-h-9 [@media(hover:none)]:md:min-h-11",
28
+ "bg-card flex min-h-11 max-w-full min-w-0 shrink items-center gap-1 rounded-xl px-3 text-sm shadow-xs md:min-h-9 [@media(hover:none)]:md:min-h-11",
29
29
  current && "text-primary"
30
30
  )}
31
31
  >
@@ -67,8 +67,8 @@ export function SidebarHeader(props: SidebarHeaderProps) {
67
67
  const workspaceId = props.workspace?.id ?? null;
68
68
  const schedules = useSchedulesGlance(workspaceId);
69
69
  return (
70
- <div className="flex flex-wrap items-center gap-x-1 px-3 pt-3 pb-2">
71
- <div className="flex min-w-[9.5rem] flex-1 items-center">
70
+ <div className="@container flex flex-wrap items-center gap-x-1 gap-y-2 px-3 pt-3 pb-2">
71
+ <div className="flex min-w-[9.5rem] flex-1 basis-[9.5rem] items-center">
72
72
  <WorkspaceSwitcher
73
73
  workspaces={props.workspaces}
74
74
  current={props.workspace}
@@ -190,7 +190,7 @@ export function SidebarHeader(props: SidebarHeaderProps) {
190
190
  ]}
191
191
  />
192
192
  </div>
193
- <div className="w-full empty:hidden">
193
+ <div className="flex w-full min-w-0 empty:hidden">
194
194
  <MachineSwitcher />
195
195
  </div>
196
196
  </div>
@@ -7,7 +7,7 @@ import { sidebarUiActions } from "@/stores/sidebarUi";
7
7
  // sessions too.
8
8
  export function SidebarSearch({ query }: { query: string }) {
9
9
  return (
10
- <label className="bg-card text-muted-foreground focus-within:ring-primary/40 flex min-h-11 min-w-[6rem] flex-1 items-center gap-2 rounded-xl px-3 shadow-xs focus-within:ring-2 md:min-h-9 [@media(hover:none)]:md:min-h-11">
10
+ <label className="bg-card text-muted-foreground focus-within:ring-primary/40 flex min-h-11 min-w-[6rem] flex-[1_1_8rem] items-center gap-2 rounded-xl px-3 shadow-xs focus-within:ring-2 md:min-h-9 [@media(hover:none)]:md:min-h-11">
11
11
  <Search className="h-3.5 w-3.5 shrink-0" />
12
12
  <input
13
13
  type="search"
@@ -168,7 +168,7 @@ export function SessionList({
168
168
  }
169
169
  pinControls={pinControls}
170
170
  />
171
- <div className="flex gap-2 px-3 pb-2">
171
+ <div className="flex flex-wrap gap-2 px-3 pb-2">
172
172
  <SidebarSearch query={ui.query} />
173
173
  <ProjectFilter
174
174
  projects={data.workspaceProjects}