@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.
- package/README.md +1 -1
- package/app/api/models/choices/route.ts +7 -3
- package/app/api/sessions/[id]/model/route.ts +2 -23
- package/components/Analyst/StartAnalystSheet.tsx +7 -2
- package/components/Analyst/useAnalystModels.ts +19 -0
- package/components/Approvals/NoApprovalsBadge.tsx +22 -9
- package/components/Away/AwayToggle.tsx +5 -5
- package/components/Chat/ChatPanel.tsx +10 -2
- package/components/Chat/Messages.test.ts +33 -0
- package/components/Chat/Messages.tsx +15 -0
- package/components/Chat/ModelPicker.tsx +3 -2
- package/components/Chat/idle.ts +9 -0
- package/components/Draft/AnalystModelChip.tsx +58 -0
- package/components/Draft/DraftChips.tsx +8 -0
- package/components/Draft/DraftPanel.tsx +7 -1
- package/components/Draft/RoleChips.tsx +12 -3
- package/components/Models/AgentRoleCard.tsx +9 -2
- package/components/Models/RoleModelSelect.tsx +23 -8
- package/components/SessionList/MachineSwitcher.tsx +1 -1
- package/components/SessionList/ProjectFilter.tsx +1 -1
- package/components/SessionList/SidebarHeader.tsx +3 -3
- package/components/SessionList/SidebarSearch.tsx +1 -1
- package/components/SessionList/index.tsx +1 -1
- package/components/Settings/NoApprovalsSection.test.ts +5 -2
- package/lib/chat/demo.ts +23 -2
- package/lib/chat/drivers/demo-script.ts +30 -4
- package/lib/chat/drivers/opencode-config.ts +5 -0
- package/lib/chat/drivers/opencode-mapper.ts +104 -17
- package/lib/chat/drivers/opencode.ts +19 -1
- package/lib/chat/drivers/turn-outcome.ts +22 -1
- package/lib/chat/events.ts +12 -1
- package/lib/chat/group.ts +11 -2
- package/lib/chat/progress.ts +4 -0
- package/lib/chat/role-models.ts +46 -0
- package/lib/chat/runner-watch.ts +1 -1
- package/lib/chat/settings.ts +7 -4
- package/lib/chat/socket.ts +3 -2
- package/lib/chat/turn-end.ts +16 -0
- package/lib/drafts.ts +2 -0
- package/lib/models/agent-binding.ts +23 -2
- package/lib/models/catalog.ts +43 -1
- package/lib/models/providers.ts +12 -0
- package/lib/models/resolve.ts +3 -7
- package/lib/models/session-model.ts +70 -4
- package/lib/models/tool-use.ts +78 -0
- package/lib/models/usable.ts +45 -0
- package/lib/updates/check.ts +32 -0
- package/lib/updates/view.ts +2 -1
- 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)
|
|
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({
|
|
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 {
|
|
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
|
-
|
|
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=
|
|
72
|
-
|
|
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
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
<
|
|
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>
|
package/components/Chat/idle.ts
CHANGED
|
@@ -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(
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
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={
|
|
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
|
-
"
|
|
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-
|
|
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-
|
|
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}
|