@saadnvd1/agent-os 0.3.736 → 0.3.738
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/app/api/hosts/[id]/agents/route.ts +22 -0
- package/app/api/hosts/[id]/route.ts +24 -1
- package/app/api/hosts/route.ts +3 -0
- package/app/api/machine/browser/route.ts +22 -0
- package/app/api/machine/route.ts +10 -0
- package/components/Chat/ChatPanel.tsx +12 -3
- package/components/Chat/Messages.test.ts +13 -1
- package/components/Chat/Messages.tsx +19 -2
- package/components/Chat/StepOutcomes.tsx +66 -0
- package/components/Chat/Tools.test.ts +52 -0
- package/components/Chat/Tools.tsx +21 -4
- package/components/Claims/ClaimsSheet.tsx +19 -1
- package/components/Claims/claims-ui.test.ts +1 -1
- package/components/Claims/format.ts +1 -1
- package/components/Draft/AgentModelChip.test.ts +43 -0
- package/components/Draft/AgentModelChip.tsx +162 -0
- package/components/Draft/Chip.tsx +9 -2
- package/components/Draft/DraftChips.tsx +7 -45
- package/components/Draft/DraftPanel.tsx +10 -1
- package/components/Hosts/AccountMachines.tsx +3 -1
- package/components/Hosts/HostRow.tsx +57 -7
- package/components/Hosts/RenameHost.tsx +59 -0
- package/components/SessionList/MachineSwitcher.tsx +67 -0
- package/components/SessionList/SidebarHeader.tsx +4 -0
- package/components/SessionList/useSidebarData.ts +21 -3
- package/data/hosts/queries.ts +17 -0
- package/data/machines/index.ts +54 -0
- package/data/machines/keys.ts +5 -0
- package/hooks/useDraftRequests.ts +6 -1
- package/lib/chat/drivers/pi-mapper.ts +17 -1
- package/lib/chat/drivers/pi.ts +13 -1
- package/lib/chat/drivers/turn-outcome.ts +25 -0
- package/lib/chat/events.ts +2 -0
- package/lib/chat/group.ts +23 -3
- package/lib/chat/page.ts +3 -0
- package/lib/chat/tool-outcome.ts +45 -0
- package/lib/db/migrations/20261023T1200_host_info.ts +20 -0
- package/lib/db/types.ts +4 -0
- package/lib/drafts.ts +8 -2
- package/lib/hosts/index.ts +43 -5
- package/lib/machines/browser.ts +37 -0
- package/lib/machines/catalog.ts +97 -0
- package/lib/machines/identity.ts +52 -0
- package/lib/machines/label.ts +36 -0
- package/lib/machines/ollama.ts +32 -0
- package/lib/machines/remote.ts +179 -0
- package/lib/machines/report.ts +8 -0
- package/lib/machines/types.ts +55 -0
- package/lib/peer/allowlist.ts +2 -0
- package/lib/peer/machines.ts +30 -2
- package/lib/sidebar/machines.ts +10 -0
- package/lib/start-options.ts +20 -4
- package/package.json +1 -1
- package/scripts/screenshots/chat-verify.ts +68 -0
- package/scripts/screenshots/seed.ts +9 -0
- package/stores/machines.ts +51 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { machineCatalog } from "@/lib/machines/catalog";
|
|
3
|
+
import { hostCatalog } from "@/lib/machines/remote";
|
|
4
|
+
import { getHost, isRemoteHost } from "@/lib/hosts";
|
|
5
|
+
import type { HostCatalogResponse } from "@/lib/machines/types";
|
|
6
|
+
|
|
7
|
+
export const dynamic = "force-dynamic";
|
|
8
|
+
|
|
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.
|
|
11
|
+
export async function GET(
|
|
12
|
+
_request: Request,
|
|
13
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
14
|
+
) {
|
|
15
|
+
const { id } = await params;
|
|
16
|
+
if (!getHost(id))
|
|
17
|
+
return NextResponse.json({ error: "Unknown machine" }, { status: 404 });
|
|
18
|
+
const body: HostCatalogResponse = isRemoteHost(id)
|
|
19
|
+
? await hostCatalog(id)
|
|
20
|
+
: { catalog: await machineCatalog(), error: null };
|
|
21
|
+
return NextResponse.json(body);
|
|
22
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import { deleteHost } from "@/lib/hosts";
|
|
2
|
+
import { deleteHost, renameHost } from "@/lib/hosts";
|
|
3
|
+
import { requireLocalTrust } from "@/lib/security/route-guard";
|
|
3
4
|
import { pinForHost } from "@/lib/peer/links";
|
|
4
5
|
|
|
5
6
|
export async function DELETE(
|
|
@@ -21,3 +22,25 @@ export async function DELETE(
|
|
|
21
22
|
return NextResponse.json({ error: message }, { status: 400 });
|
|
22
23
|
}
|
|
23
24
|
}
|
|
25
|
+
|
|
26
|
+
// Rename a machine; "local" is this one, and its name is the one Work across
|
|
27
|
+
// machines shows too.
|
|
28
|
+
export async function PATCH(
|
|
29
|
+
request: NextRequest,
|
|
30
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
31
|
+
) {
|
|
32
|
+
const denied = requireLocalTrust(request);
|
|
33
|
+
if (denied) return denied;
|
|
34
|
+
try {
|
|
35
|
+
const id = (await params).id;
|
|
36
|
+
const body = (await request.json().catch(() => null)) as {
|
|
37
|
+
name?: unknown;
|
|
38
|
+
} | null;
|
|
39
|
+
return NextResponse.json({
|
|
40
|
+
name: renameHost(id, String(body?.name ?? "")),
|
|
41
|
+
});
|
|
42
|
+
} catch (error) {
|
|
43
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
44
|
+
return NextResponse.json({ error: message }, { status: 400 });
|
|
45
|
+
}
|
|
46
|
+
}
|
package/app/api/hosts/route.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { createHost, listHosts } from "@/lib/hosts";
|
|
3
|
+
import { refreshHostInfo } from "@/lib/machines/remote";
|
|
3
4
|
|
|
4
5
|
export async function GET() {
|
|
6
|
+
// Linked machines' names and OS, for the next read; never waited on.
|
|
7
|
+
refreshHostInfo();
|
|
5
8
|
return NextResponse.json({ hosts: listHosts() });
|
|
6
9
|
}
|
|
7
10
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
2
|
+
import { browserMachine } from "@/lib/machines/browser";
|
|
3
|
+
import { thisMachine } from "@/lib/machines/identity";
|
|
4
|
+
import { allHostInfo } from "@/lib/machines/remote";
|
|
5
|
+
import { REMOTE_HEADER, TRUST_HEADER } from "@/lib/security/auth";
|
|
6
|
+
|
|
7
|
+
export const dynamic = "force-dynamic";
|
|
8
|
+
|
|
9
|
+
// Which machine this browser is on, when the connection says.
|
|
10
|
+
export function GET(request: NextRequest) {
|
|
11
|
+
return NextResponse.json(
|
|
12
|
+
browserMachine(
|
|
13
|
+
request.headers.get(TRUST_HEADER),
|
|
14
|
+
request.headers.get(REMOTE_HEADER),
|
|
15
|
+
thisMachine().addresses,
|
|
16
|
+
allHostInfo().map((i) => ({
|
|
17
|
+
hostId: i.hostId,
|
|
18
|
+
addresses: i.addresses,
|
|
19
|
+
}))
|
|
20
|
+
)
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { thisMachineReport } from "@/lib/machines/report";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
|
|
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());
|
|
10
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import type { StepCounts } from "@/lib/chat/tool-outcome";
|
|
3
4
|
import {
|
|
4
5
|
memo,
|
|
5
6
|
useCallback,
|
|
@@ -99,7 +100,15 @@ interface ItemActions {
|
|
|
99
100
|
onQuote?: (text: string) => void;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
function Item({
|
|
103
|
+
function Item({
|
|
104
|
+
item,
|
|
105
|
+
actions,
|
|
106
|
+
steps,
|
|
107
|
+
}: {
|
|
108
|
+
item: ChatItem;
|
|
109
|
+
actions: ItemActions;
|
|
110
|
+
steps?: StepCounts;
|
|
111
|
+
}) {
|
|
103
112
|
switch (item.kind) {
|
|
104
113
|
case "user":
|
|
105
114
|
return (
|
|
@@ -119,7 +128,7 @@ function Item({ item, actions }: { item: ChatItem; actions: ItemActions }) {
|
|
|
119
128
|
case "todos":
|
|
120
129
|
return <Todos item={item} />;
|
|
121
130
|
case "turn_end":
|
|
122
|
-
return <TurnEnd item={item} />;
|
|
131
|
+
return <TurnEnd item={item} steps={steps} />;
|
|
123
132
|
case "error":
|
|
124
133
|
return <ErrorMessage item={item} />;
|
|
125
134
|
case "command_output":
|
|
@@ -209,7 +218,7 @@ const Block = memo(
|
|
|
209
218
|
</UndoneBlock>
|
|
210
219
|
) : (
|
|
211
220
|
<div data-item={b.item.id}>
|
|
212
|
-
<Item item={b.item} actions={actions} />
|
|
221
|
+
<Item item={b.item} actions={actions} steps={b.steps} />
|
|
213
222
|
</div>
|
|
214
223
|
);
|
|
215
224
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createElement } from "react";
|
|
2
2
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
3
|
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
import { AssistantMessage, UserMessage } from "./Messages";
|
|
4
|
+
import { AssistantMessage, TurnEnd, UserMessage } from "./Messages";
|
|
5
5
|
|
|
6
6
|
const item = {
|
|
7
7
|
id: "a1",
|
|
@@ -119,3 +119,15 @@ describe("UserMessage code blocks", () => {
|
|
|
119
119
|
}
|
|
120
120
|
});
|
|
121
121
|
});
|
|
122
|
+
|
|
123
|
+
describe("TurnEnd steps", () => {
|
|
124
|
+
it("says what a stopped turn got through", () => {
|
|
125
|
+
const html = renderToStaticMarkup(
|
|
126
|
+
createElement(TurnEnd, {
|
|
127
|
+
item: { id: "e", kind: "turn_end", interrupted: true } as never,
|
|
128
|
+
steps: { total: 5, ok: 3, failed: 1, stopped: 1, running: 0 },
|
|
129
|
+
})
|
|
130
|
+
);
|
|
131
|
+
expect(html).toContain("Stopped · 4 of 5 steps done, 1 failed, 1 not run");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import type { StepCounts } from "@/lib/chat/tool-outcome";
|
|
3
4
|
import { memo } from "react";
|
|
4
5
|
import { useSnapshot } from "valtio";
|
|
5
6
|
import {
|
|
@@ -378,7 +379,23 @@ export function Todos({ item }: { item: Of<"todos"> }) {
|
|
|
378
379
|
);
|
|
379
380
|
}
|
|
380
381
|
|
|
381
|
-
|
|
382
|
+
// What a stopped or failed turn got through.
|
|
383
|
+
export function stepsLabel(s: StepCounts): string {
|
|
384
|
+
const done = s.ok + s.failed;
|
|
385
|
+
const parts = [`${done} of ${s.total} steps done`];
|
|
386
|
+
if (s.failed) parts.push(`${s.failed} failed`);
|
|
387
|
+
const unfinished = s.stopped + s.running;
|
|
388
|
+
if (unfinished) parts.push(`${unfinished} not run`);
|
|
389
|
+
return parts.join(", ");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function TurnEnd({
|
|
393
|
+
item,
|
|
394
|
+
steps,
|
|
395
|
+
}: {
|
|
396
|
+
item: Of<"turn_end">;
|
|
397
|
+
steps?: StepCounts;
|
|
398
|
+
}) {
|
|
382
399
|
const secs = item.durationMs ? Math.round(item.durationMs / 1000) : null;
|
|
383
400
|
const early = !item.interrupted && item.ended;
|
|
384
401
|
const label = item.interrupted
|
|
@@ -407,7 +424,7 @@ export function TurnEnd({ item }: { item: Of<"turn_end"> }) {
|
|
|
407
424
|
)}
|
|
408
425
|
<div className="group text-muted-foreground/60 flex items-center gap-3 font-mono text-[11px]">
|
|
409
426
|
<span className="bg-foreground/[0.06] h-px flex-1" />
|
|
410
|
-
{label}
|
|
427
|
+
{steps ? `${label} · ${stepsLabel(steps)}` : label}
|
|
411
428
|
<TurnCopy turnEndId={item.id} />
|
|
412
429
|
<TurnBookmark turnEndId={item.id} />
|
|
413
430
|
<span className="bg-foreground/[0.06] h-px flex-1" />
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ToolItem } from "@/lib/chat/group";
|
|
4
|
+
import { toolOutcome } from "@/lib/chat/tool-outcome";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import { StatusIcon } from "./Tools";
|
|
7
|
+
|
|
8
|
+
export const MAX_CHIPS = 24;
|
|
9
|
+
|
|
10
|
+
const WORD = {
|
|
11
|
+
running: "running",
|
|
12
|
+
done: "ok",
|
|
13
|
+
error: "failed",
|
|
14
|
+
stopped: "not run",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
// One line per collapsed group: how each step ended, so the results aren't
|
|
18
|
+
// lost behind the fold.
|
|
19
|
+
export function StepOutcomes({
|
|
20
|
+
tools,
|
|
21
|
+
onOpen,
|
|
22
|
+
}: {
|
|
23
|
+
tools: ToolItem[];
|
|
24
|
+
onOpen: () => void;
|
|
25
|
+
}) {
|
|
26
|
+
const shown = tools.slice(0, MAX_CHIPS);
|
|
27
|
+
return (
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
onClick={onOpen}
|
|
31
|
+
aria-label="Show steps"
|
|
32
|
+
className="flex min-h-11 w-full flex-wrap items-center gap-x-3 gap-y-1 px-2 pb-1.5 text-left md:min-h-0"
|
|
33
|
+
>
|
|
34
|
+
{shown.map((t) => {
|
|
35
|
+
const code = toolOutcome(t);
|
|
36
|
+
return (
|
|
37
|
+
<span
|
|
38
|
+
key={t.id}
|
|
39
|
+
title={`${t.title}: ${WORD[t.status]}${code ? `, ${code}` : ""}`}
|
|
40
|
+
aria-label={`${t.title}: ${WORD[t.status]}${code ? `, ${code}` : ""}`}
|
|
41
|
+
className="flex min-w-0 items-center gap-1"
|
|
42
|
+
>
|
|
43
|
+
<StatusIcon status={t.status} />
|
|
44
|
+
{code && (
|
|
45
|
+
<span
|
|
46
|
+
className={cn(
|
|
47
|
+
"font-mono text-[11px]",
|
|
48
|
+
t.status === "error"
|
|
49
|
+
? "text-destructive"
|
|
50
|
+
: "text-muted-foreground"
|
|
51
|
+
)}
|
|
52
|
+
>
|
|
53
|
+
{code}
|
|
54
|
+
</span>
|
|
55
|
+
)}
|
|
56
|
+
</span>
|
|
57
|
+
);
|
|
58
|
+
})}
|
|
59
|
+
{tools.length > shown.length && (
|
|
60
|
+
<span className="text-muted-foreground/70 font-mono text-[11px]">
|
|
61
|
+
+{tools.length - shown.length} more
|
|
62
|
+
</span>
|
|
63
|
+
)}
|
|
64
|
+
</button>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createElement } from "react";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import type { ToolItem } from "@/lib/chat/group";
|
|
5
|
+
import { MAX_CHIPS } from "./StepOutcomes";
|
|
6
|
+
import { ToolGroup } from "./Tools";
|
|
7
|
+
|
|
8
|
+
const tool = (id: string, over: Partial<ToolItem> = {}): ToolItem => ({
|
|
9
|
+
id,
|
|
10
|
+
kind: "tool",
|
|
11
|
+
name: "Bash",
|
|
12
|
+
title: `check ${id}`,
|
|
13
|
+
input: {},
|
|
14
|
+
status: "done",
|
|
15
|
+
createdAt: 0,
|
|
16
|
+
...over,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const render = (tools: ToolItem[]) =>
|
|
20
|
+
renderToStaticMarkup(createElement(ToolGroup, { tools }));
|
|
21
|
+
|
|
22
|
+
describe("collapsed ToolGroup", () => {
|
|
23
|
+
it("keeps each step's outcome in view", () => {
|
|
24
|
+
const html = render([
|
|
25
|
+
tool("a", { output: "HTTP/2 200 \n" }),
|
|
26
|
+
tool("b", { status: "error", output: "Exit code 7" }),
|
|
27
|
+
tool("c", { status: "error", deferred: true, outcome: "HTTP 404" }),
|
|
28
|
+
tool("d", { status: "stopped" }),
|
|
29
|
+
]);
|
|
30
|
+
expect(html).toContain("4 steps");
|
|
31
|
+
expect(html).toContain("1 ok");
|
|
32
|
+
expect(html).toContain("2 failed");
|
|
33
|
+
expect(html).toContain("1 not run");
|
|
34
|
+
expect(html).toContain("check a: ok, HTTP 200");
|
|
35
|
+
expect(html).toContain("check b: failed, exit 7");
|
|
36
|
+
expect(html).toContain("check c: failed, HTTP 404");
|
|
37
|
+
expect(html).toContain("check d: not run");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("shows no strip while a step runs", () => {
|
|
41
|
+
expect(render([tool("a", { status: "running" })])).not.toContain(
|
|
42
|
+
"Show steps"
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("caps the strip", () => {
|
|
47
|
+
const tools = Array.from({ length: MAX_CHIPS + 3 }, (_, i) =>
|
|
48
|
+
tool(`x${i}`)
|
|
49
|
+
);
|
|
50
|
+
expect(render(tools)).toContain("+3 more");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -8,6 +8,8 @@ import { lineDiff, shortPath, type DiffLine } from "@/lib/chat/diff";
|
|
|
8
8
|
import { needsBody } from "@/lib/chat/bodyCache";
|
|
9
9
|
import { cn } from "@/lib/utils";
|
|
10
10
|
import { formatElapsed } from "@/lib/chat/elapsed";
|
|
11
|
+
import { stepCounts, toolOutcome } from "@/lib/chat/tool-outcome";
|
|
12
|
+
import { StepOutcomes } from "./StepOutcomes";
|
|
11
13
|
import { Highlighted } from "./Code";
|
|
12
14
|
import { BackgroundButton, BackgroundState, useBackground } from "./background";
|
|
13
15
|
import { useNow } from "./useNow";
|
|
@@ -163,6 +165,18 @@ function ToolRow({ tool }: { tool: ToolItem }) {
|
|
|
163
165
|
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
|
164
166
|
{tool.title}
|
|
165
167
|
</span>
|
|
168
|
+
{toolOutcome(tool) && (
|
|
169
|
+
<span
|
|
170
|
+
className={cn(
|
|
171
|
+
"shrink-0 font-mono text-[11px]",
|
|
172
|
+
tool.status === "error"
|
|
173
|
+
? "text-destructive"
|
|
174
|
+
: "text-muted-foreground"
|
|
175
|
+
)}
|
|
176
|
+
>
|
|
177
|
+
{toolOutcome(tool)}
|
|
178
|
+
</span>
|
|
179
|
+
)}
|
|
166
180
|
{tool.backgroundTaskId ? (
|
|
167
181
|
<BackgroundState tool={tool} />
|
|
168
182
|
) : (
|
|
@@ -180,8 +194,7 @@ function ToolRow({ tool }: { tool: ToolItem }) {
|
|
|
180
194
|
// A run of tool calls: one quiet line, expandable to every step.
|
|
181
195
|
export function ToolGroup({ tools }: { tools: ToolItem[] }) {
|
|
182
196
|
const running = tools.some((t) => t.status === "running");
|
|
183
|
-
const
|
|
184
|
-
const stopped = tools.filter((t) => t.status === "stopped").length;
|
|
197
|
+
const { ok, failed, stopped } = stepCounts(tools);
|
|
185
198
|
const edits = tools.filter((t) => t.diff || t.hasDiff).length;
|
|
186
199
|
const moved = tools.filter((t) => t.backgroundTaskId).length;
|
|
187
200
|
const [open, setOpen] = useRowState(`steps:${tools[0].id}`, false);
|
|
@@ -211,13 +224,14 @@ export function ToolGroup({ tools }: { tools: ToolItem[] }) {
|
|
|
211
224
|
{!running &&
|
|
212
225
|
edits > 0 &&
|
|
213
226
|
` · ${edits} edit${edits === 1 ? "" : "s"}`}
|
|
227
|
+
{!running && (failed > 0 || stopped > 0) && ` · ${ok} ok`}
|
|
214
228
|
</span>
|
|
215
229
|
{running && <Elapsed tool={latest} />}
|
|
216
230
|
{failed > 0 && (
|
|
217
231
|
<span className="text-destructive">{failed} failed</span>
|
|
218
232
|
)}
|
|
219
|
-
{stopped > 0 &&
|
|
220
|
-
<span className="text-muted-foreground/70">stopped</span>
|
|
233
|
+
{stopped > 0 && (
|
|
234
|
+
<span className="text-muted-foreground/70">{stopped} not run</span>
|
|
221
235
|
)}
|
|
222
236
|
{!running && moved > 0 && (
|
|
223
237
|
<span className="text-muted-foreground/70">
|
|
@@ -227,6 +241,9 @@ export function ToolGroup({ tools }: { tools: ToolItem[] }) {
|
|
|
227
241
|
</button>
|
|
228
242
|
{running && <BackgroundButton tool={latest} />}
|
|
229
243
|
</div>
|
|
244
|
+
{!open && !running && (
|
|
245
|
+
<StepOutcomes tools={tools} onOpen={() => setOpen(true)} />
|
|
246
|
+
)}
|
|
230
247
|
{open && (
|
|
231
248
|
<div className="pb-1">
|
|
232
249
|
{tools.map((t) => (
|
|
@@ -13,6 +13,9 @@ import {
|
|
|
13
13
|
} from "@/components/ui/dialog";
|
|
14
14
|
import { useNow } from "@/components/Chat/useNow";
|
|
15
15
|
import { useClaims } from "@/data/claims";
|
|
16
|
+
import { useHostsQuery } from "@/data/hosts";
|
|
17
|
+
import { useBrowserMachine } from "@/data/machines";
|
|
18
|
+
import { machineLabel } from "@/lib/machines/label";
|
|
16
19
|
import { claimsUi, claimsUiActions } from "@/stores/claimsUi";
|
|
17
20
|
import { cn } from "@/lib/utils";
|
|
18
21
|
import { ClaimRow } from "./ClaimRow";
|
|
@@ -50,6 +53,12 @@ export function ClaimsSheet() {
|
|
|
50
53
|
.flatMap((o) => o.shared)
|
|
51
54
|
);
|
|
52
55
|
const here = data?.machine.id;
|
|
56
|
+
const browser = useBrowserMachine();
|
|
57
|
+
const { data: hosts } = useHostsQuery();
|
|
58
|
+
// The browser's machine by name: claims know machines by their own ids.
|
|
59
|
+
const browserName = hosts
|
|
60
|
+
?.find((h) => h.id === browser.hostId)
|
|
61
|
+
?.name.toLowerCase();
|
|
53
62
|
|
|
54
63
|
return (
|
|
55
64
|
<Dialog open={snap.open} onOpenChange={claimsUiActions.setOpen}>
|
|
@@ -77,7 +86,16 @@ export function ClaimsSheet() {
|
|
|
77
86
|
key={m.id}
|
|
78
87
|
className="bg-muted/40 flex items-center gap-2 rounded-lg px-3 py-2 text-xs"
|
|
79
88
|
>
|
|
80
|
-
<span className="font-medium">
|
|
89
|
+
<span className="font-medium">
|
|
90
|
+
{machineLabel(
|
|
91
|
+
{ id: m.self ? "local" : m.id, name: m.name },
|
|
92
|
+
m.self
|
|
93
|
+
? browser.hostId
|
|
94
|
+
: m.name.toLowerCase() === browserName
|
|
95
|
+
? m.id
|
|
96
|
+
: null
|
|
97
|
+
)}
|
|
98
|
+
</span>
|
|
81
99
|
<span
|
|
82
100
|
className={cn(
|
|
83
101
|
"text-muted-foreground",
|
|
@@ -103,7 +103,7 @@ describe("claims keys and helpers", () => {
|
|
|
103
103
|
expect(startedText({ startedAt: 0 }, 12 * 60_000)).toBe("started 12m ago");
|
|
104
104
|
expect(startedText({ startedAt: 0 }, 5_000)).toBe("just started");
|
|
105
105
|
expect(lastHeardText(0, 6 * 60_000)).toBe("last heard 6m ago");
|
|
106
|
-
expect(lastHeardText(null, 0)).toBe("this
|
|
106
|
+
expect(lastHeardText(null, 0)).toBe("serves this dashboard");
|
|
107
107
|
});
|
|
108
108
|
|
|
109
109
|
const c = (id: string, repo: string, startedAt: number) =>
|
|
@@ -17,7 +17,7 @@ export const startedText = (c: Pick<Claim, "startedAt">, now: number) => {
|
|
|
17
17
|
|
|
18
18
|
/** "last heard 6m ago". */
|
|
19
19
|
export const lastHeardText = (at: number | null, now: number) => {
|
|
20
|
-
if (at === null) return "this
|
|
20
|
+
if (at === null) return "serves this dashboard";
|
|
21
21
|
const s = span(now - at);
|
|
22
22
|
return s === "under a minute" ? "heard just now" : `last heard ${s} ago`;
|
|
23
23
|
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { CatalogAgent, MachineCatalog } from "@/lib/machines/types";
|
|
3
|
+
import { catalogBlocker, usableAgent } from "./AgentModelChip";
|
|
4
|
+
|
|
5
|
+
const agent = (over: Partial<CatalogAgent>): CatalogAgent => ({
|
|
6
|
+
agent: "claude",
|
|
7
|
+
label: "Claude Code",
|
|
8
|
+
installed: true,
|
|
9
|
+
auth: "ready",
|
|
10
|
+
hint: null,
|
|
11
|
+
models: [],
|
|
12
|
+
defaultModel: "opus",
|
|
13
|
+
taskBlocked: null,
|
|
14
|
+
modelsError: null,
|
|
15
|
+
...over,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("usableAgent", () => {
|
|
19
|
+
const catalog: MachineCatalog = {
|
|
20
|
+
agents: [
|
|
21
|
+
agent({ agent: "codex", installed: false }),
|
|
22
|
+
agent({ agent: "opencode", defaultModel: "default" }),
|
|
23
|
+
agent({ agent: "claude", auth: "needs-login" }),
|
|
24
|
+
],
|
|
25
|
+
ollama: [],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
it("keeps an agent the machine can run", () => {
|
|
29
|
+
expect(usableAgent(catalog, "opencode")).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("moves to the first agent that is installed and signed in", () => {
|
|
33
|
+
expect(usableAgent(catalog, "codex")?.agent).toBe("opencode");
|
|
34
|
+
expect(usableAgent(catalog, "claude")?.agent).toBe("opencode");
|
|
35
|
+
expect(usableAgent(catalog, "aider")?.agent).toBe("opencode");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("names what blocks an agent", () => {
|
|
39
|
+
expect(catalogBlocker(catalog.agents[0])).toBe("Not installed");
|
|
40
|
+
expect(catalogBlocker(catalog.agents[2])).toBe("Needs sign-in");
|
|
41
|
+
expect(catalogBlocker(catalog.agents[1])).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { Bot } from "lucide-react";
|
|
5
|
+
import type { Draft } from "@/lib/drafts";
|
|
6
|
+
import { AGENT_OPTIONS, agentLabel } from "@/lib/agent-options";
|
|
7
|
+
import { resolveModelForAgent } from "@/lib/model-catalog";
|
|
8
|
+
import { useMachineCatalog } from "@/data/machines";
|
|
9
|
+
import type { CatalogAgent, MachineCatalog } from "@/lib/machines/types";
|
|
10
|
+
import { ChipHeading, ChipItem, ChipMenu } from "./Chip";
|
|
11
|
+
|
|
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
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
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.
|
|
33
|
+
*/
|
|
34
|
+
export function AgentModelChip({
|
|
35
|
+
draft,
|
|
36
|
+
onChange,
|
|
37
|
+
}: {
|
|
38
|
+
draft: Draft;
|
|
39
|
+
onChange: (patch: Partial<Draft>) => void;
|
|
40
|
+
}) {
|
|
41
|
+
const { data, isPending } = useMachineCatalog(draft.hostId);
|
|
42
|
+
const catalog = data?.catalog ?? null;
|
|
43
|
+
|
|
44
|
+
// The machine changed under the draft: move to an agent it has.
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!catalog) return;
|
|
47
|
+
const other = usableAgent(catalog, draft.agentType);
|
|
48
|
+
if (other) onChange({ agentType: other.agent, model: other.defaultModel });
|
|
49
|
+
else {
|
|
50
|
+
const mine = catalog.agents.find((a) => a.agent === draft.agentType);
|
|
51
|
+
if (
|
|
52
|
+
mine &&
|
|
53
|
+
!mine.modelsError &&
|
|
54
|
+
!mine.models.some((m) => m.value === draft.model)
|
|
55
|
+
)
|
|
56
|
+
onChange({ model: mine.defaultModel });
|
|
57
|
+
}
|
|
58
|
+
}, [catalog, draft.agentType, draft.model, onChange]);
|
|
59
|
+
|
|
60
|
+
const current = catalog?.agents.find((a) => a.agent === draft.agentType);
|
|
61
|
+
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
|
+
|
|
67
|
+
const blocker = current ? catalogBlocker(current) : null;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<>
|
|
71
|
+
<ChipMenu icon={Bot} name="Agent" label={label}>
|
|
72
|
+
{!catalog &&
|
|
73
|
+
AGENT_OPTIONS.map((o) => (
|
|
74
|
+
<ChipItem
|
|
75
|
+
key={o.value}
|
|
76
|
+
checked={o.value === draft.agentType}
|
|
77
|
+
hint={
|
|
78
|
+
isPending
|
|
79
|
+
? "Checking the machine…"
|
|
80
|
+
: (data?.error ?? "Couldn't check the machine")
|
|
81
|
+
}
|
|
82
|
+
onSelect={() =>
|
|
83
|
+
onChange({
|
|
84
|
+
agentType: o.value,
|
|
85
|
+
model: resolveModelForAgent(o.value, draft.model),
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
>
|
|
89
|
+
{o.label}
|
|
90
|
+
</ChipItem>
|
|
91
|
+
))}
|
|
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'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>
|
|
154
|
+
{blocker && (
|
|
155
|
+
<p className="w-full text-xs text-amber-600 dark:text-amber-400">
|
|
156
|
+
{blocker}
|
|
157
|
+
{current?.hint && blocker !== current.hint ? `: ${current.hint}` : ""}
|
|
158
|
+
</p>
|
|
159
|
+
)}
|
|
160
|
+
</>
|
|
161
|
+
);
|
|
162
|
+
}
|