@saadnvd1/agent-os 0.3.736 → 0.3.737
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/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/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/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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -90,10 +90,17 @@ export function ChipItem({
|
|
|
90
90
|
);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
export function ChipHeading({
|
|
93
|
+
export function ChipHeading({
|
|
94
|
+
children,
|
|
95
|
+
first,
|
|
96
|
+
}: {
|
|
97
|
+
children: React.ReactNode;
|
|
98
|
+
// The top of the menu: nothing above it to separate from.
|
|
99
|
+
first?: boolean;
|
|
100
|
+
}) {
|
|
94
101
|
return (
|
|
95
102
|
<>
|
|
96
|
-
<DropdownMenuSeparator />
|
|
103
|
+
{!first && <DropdownMenuSeparator />}
|
|
97
104
|
<DropdownMenuLabel className="text-muted-foreground text-[11px] font-medium">
|
|
98
105
|
{children}
|
|
99
106
|
</DropdownMenuLabel>
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect } from "react";
|
|
4
4
|
import {
|
|
5
|
-
Bot,
|
|
6
5
|
Cpu,
|
|
7
6
|
Eye,
|
|
8
7
|
FolderGit2,
|
|
@@ -15,14 +14,13 @@ import {
|
|
|
15
14
|
import type { Host } from "@/lib/db";
|
|
16
15
|
import type { Project } from "@/lib/db";
|
|
17
16
|
import { draftHostGone, newDraft, type Draft } from "@/lib/drafts";
|
|
18
|
-
import { AGENT_OPTIONS, agentLabel } from "@/lib/agent-options";
|
|
19
|
-
import { resolveModelForAgent } from "@/lib/model-catalog";
|
|
20
17
|
import type { GitCheck } from "@/data/git/queries";
|
|
21
|
-
import { useAgentStatusQuery } from "@/data/agents";
|
|
22
18
|
import { useProjectMachinesQuery } from "@/data/projects";
|
|
23
19
|
import { modelChoices, useModelsQuery } from "@/data/models";
|
|
24
|
-
import
|
|
20
|
+
import { useBrowserMachine } from "@/data/machines";
|
|
21
|
+
import { machineLabel } from "@/lib/machines/label";
|
|
25
22
|
import { RoleChips } from "./RoleChips";
|
|
23
|
+
import { AgentModelChip } from "./AgentModelChip";
|
|
26
24
|
import { ChipHeading, ChipItem, ChipMenu, ChipToggle } from "./Chip";
|
|
27
25
|
import {
|
|
28
26
|
DEFAULT_PRIORITY,
|
|
@@ -31,14 +29,6 @@ import {
|
|
|
31
29
|
PRIORITY_LABEL,
|
|
32
30
|
} from "@/lib/tasks/schedule-types";
|
|
33
31
|
|
|
34
|
-
// What stands between an agent and its first prompt, if anything.
|
|
35
|
-
export function agentBlocker(probe?: AgentProbe): string | null {
|
|
36
|
-
if (!probe) return null;
|
|
37
|
-
if (!probe.installed) return "Not installed";
|
|
38
|
-
if (probe.auth === "needs-login") return "Needs sign-in";
|
|
39
|
-
return null;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
32
|
const BRANCHES_SHOWN = 20;
|
|
43
33
|
|
|
44
34
|
// A draft's choices, above its composer: where it runs and how.
|
|
@@ -58,6 +48,7 @@ export function DraftChips({
|
|
|
58
48
|
git: GitCheck | undefined;
|
|
59
49
|
onChange: (patch: Partial<Draft>) => void;
|
|
60
50
|
}) {
|
|
51
|
+
const browser = useBrowserMachine();
|
|
61
52
|
const real = projects.filter((p) => !p.is_uncategorized);
|
|
62
53
|
const project = real.find((p) => p.id === draft.projectId) ?? null;
|
|
63
54
|
const projectHere = !!project && (project.host_id || "local") === "local";
|
|
@@ -70,10 +61,6 @@ export function DraftChips({
|
|
|
70
61
|
: hosts;
|
|
71
62
|
const host = hosts.find((h) => h.id === draft.hostId);
|
|
72
63
|
const local = draft.hostId === "local";
|
|
73
|
-
// Which agents are installed and signed in, on this machine: a draft for
|
|
74
|
-
// another machine shows them all.
|
|
75
|
-
const { data: localProbes } = useAgentStatusQuery();
|
|
76
|
-
const probes = local ? localProbes : undefined;
|
|
77
64
|
const base = draft.baseBranch ?? git?.defaultBranch ?? "main";
|
|
78
65
|
const { data: models } = useModelsQuery(draft.openPr);
|
|
79
66
|
// A project elsewhere runs where it lives; a scratch chat, a task or a
|
|
@@ -147,7 +134,7 @@ export function DraftChips({
|
|
|
147
134
|
<ChipMenu
|
|
148
135
|
icon={Monitor}
|
|
149
136
|
name="Machine"
|
|
150
|
-
label={host
|
|
137
|
+
label={host ? machineLabel(host) : draft.hostId}
|
|
151
138
|
disabled={!canMove}
|
|
152
139
|
title={canMove ? undefined : "Runs where the project lives"}
|
|
153
140
|
>
|
|
@@ -164,31 +151,14 @@ export function DraftChips({
|
|
|
164
151
|
})
|
|
165
152
|
}
|
|
166
153
|
>
|
|
167
|
-
{h.
|
|
154
|
+
{machineLabel(h, browser.hostId)}
|
|
168
155
|
</ChipItem>
|
|
169
156
|
))}
|
|
170
157
|
</ChipMenu>
|
|
171
158
|
)}
|
|
172
159
|
|
|
173
160
|
{!draft.openPr && !draft.agentRole && (
|
|
174
|
-
<
|
|
175
|
-
{AGENT_OPTIONS.map((o) => (
|
|
176
|
-
<ChipItem
|
|
177
|
-
key={o.value}
|
|
178
|
-
checked={o.value === draft.agentType}
|
|
179
|
-
disabled={probes?.[o.value] ? !probes[o.value].installed : false}
|
|
180
|
-
hint={agentBlocker(probes?.[o.value]) ?? undefined}
|
|
181
|
-
onSelect={() =>
|
|
182
|
-
onChange({
|
|
183
|
-
agentType: o.value,
|
|
184
|
-
model: resolveModelForAgent(o.value, draft.model),
|
|
185
|
-
})
|
|
186
|
-
}
|
|
187
|
-
>
|
|
188
|
-
{o.label}
|
|
189
|
-
</ChipItem>
|
|
190
|
-
))}
|
|
191
|
-
</ChipMenu>
|
|
161
|
+
<AgentModelChip draft={draft} onChange={onChange} />
|
|
192
162
|
)}
|
|
193
163
|
|
|
194
164
|
{!draft.openPr && local && (
|
|
@@ -314,14 +284,6 @@ export function DraftChips({
|
|
|
314
284
|
onChange={(terminal) => onChange({ terminal })}
|
|
315
285
|
/>
|
|
316
286
|
)}
|
|
317
|
-
{!draft.openPr && agentBlocker(probes?.[draft.agentType]) && (
|
|
318
|
-
<p className="w-full text-xs text-amber-600 dark:text-amber-400">
|
|
319
|
-
{agentBlocker(probes?.[draft.agentType])}
|
|
320
|
-
{probes?.[draft.agentType]?.hint
|
|
321
|
-
? `: ${probes[draft.agentType].hint}`
|
|
322
|
-
: ""}
|
|
323
|
-
</p>
|
|
324
|
-
)}
|
|
325
287
|
</div>
|
|
326
288
|
);
|
|
327
289
|
}
|
|
@@ -27,6 +27,7 @@ import { useStartAnalystBeside } from "@/components/Analyst/pair";
|
|
|
27
27
|
import { DraftChips } from "./DraftChips";
|
|
28
28
|
import { DraftHeading } from "./DraftHeading";
|
|
29
29
|
import { ModelUnavailableDialog } from "./ModelUnavailableDialog";
|
|
30
|
+
import { useMachineCatalog } from "@/data/machines";
|
|
30
31
|
import { draftModels } from "./draftModels";
|
|
31
32
|
|
|
32
33
|
// A new session before its first send: choices on top, a composer below.
|
|
@@ -57,6 +58,8 @@ export function DraftPanel({
|
|
|
57
58
|
draft?.projectId ?? null,
|
|
58
59
|
discovers
|
|
59
60
|
);
|
|
61
|
+
// On another machine the models are the ones it reports, not this one's.
|
|
62
|
+
const there = useMachineCatalog(draft?.hostId ?? "local");
|
|
60
63
|
const launch = useLaunchSession();
|
|
61
64
|
const startAnalyst = useStartAnalystBeside();
|
|
62
65
|
const { workspace } = useSelectedWorkspace();
|
|
@@ -253,7 +256,13 @@ export function DraftPanel({
|
|
|
253
256
|
}
|
|
254
257
|
onSend={(text, images, files) => void send(text, images, files)}
|
|
255
258
|
onStop={() => {}}
|
|
256
|
-
models={
|
|
259
|
+
models={
|
|
260
|
+
(draft.hostId !== "local" &&
|
|
261
|
+
there.data?.catalog?.agents.find(
|
|
262
|
+
(a) => a.agent === draft.agentType
|
|
263
|
+
)?.models) ||
|
|
264
|
+
draftModels(draft.agentType, found)
|
|
265
|
+
}
|
|
257
266
|
model={draft.model}
|
|
258
267
|
onSetModel={(model) => draftsActions.update(draft.id, { model })}
|
|
259
268
|
access={draft.openPr ? undefined : draft.access}
|
|
@@ -55,7 +55,9 @@ function MachineRow({
|
|
|
55
55
|
</div>
|
|
56
56
|
{linked ? (
|
|
57
57
|
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
|
|
58
|
-
|
|
58
|
+
{machine.linkedVia === "tailscale"
|
|
59
|
+
? "Linked over Tailscale"
|
|
60
|
+
: "Linked"}
|
|
59
61
|
</span>
|
|
60
62
|
) : machine.linkState === "suspended" ? (
|
|
61
63
|
<span
|
|
@@ -11,15 +11,20 @@ import {
|
|
|
11
11
|
PlugZap,
|
|
12
12
|
Loader2,
|
|
13
13
|
Link2,
|
|
14
|
+
MapPin,
|
|
15
|
+
Pencil,
|
|
14
16
|
Unlink,
|
|
15
17
|
} from "lucide-react";
|
|
16
18
|
import { Notice } from "@/components/ui/notice";
|
|
17
19
|
import type { Host } from "@/lib/db";
|
|
18
20
|
import { useDeleteHost, useLinkHost, useTestHost } from "@/data/hosts";
|
|
21
|
+
import { useBrowserMachine } from "@/data/machines";
|
|
22
|
+
import { machineDetail, machineLabel } from "@/lib/machines/label";
|
|
19
23
|
import { useUnlinkPeer } from "@/data/peers";
|
|
20
24
|
import { NEEDS_PRO, UPGRADE_URL } from "@/lib/peer/reasons";
|
|
21
25
|
import { cn } from "@/lib/utils";
|
|
22
26
|
import { PeerActivitySheet } from "./PeerActivitySheet";
|
|
27
|
+
import { RenameHost } from "./RenameHost";
|
|
23
28
|
|
|
24
29
|
function connectLine(host: Host): string {
|
|
25
30
|
if (host.peer_state === "active")
|
|
@@ -67,6 +72,9 @@ export function HostRow({ host }: { host: Host }) {
|
|
|
67
72
|
const unlink = useUnlinkPeer();
|
|
68
73
|
const [activity, setActivity] = useState(false);
|
|
69
74
|
const [confirmUnlink, setConfirmUnlink] = useState(false);
|
|
75
|
+
const [renaming, setRenaming] = useState(false);
|
|
76
|
+
const browser = useBrowserMachine();
|
|
77
|
+
const here = browser.hostId === host.id;
|
|
70
78
|
const isLocal = host.id === "local";
|
|
71
79
|
const viaConnect = host.kind === "connect";
|
|
72
80
|
const pinned =
|
|
@@ -80,17 +88,58 @@ export function HostRow({ host }: { host: Host }) {
|
|
|
80
88
|
<div className="flex items-center gap-3">
|
|
81
89
|
<Icon className="text-muted-foreground h-4 w-4 shrink-0" />
|
|
82
90
|
<div className="min-w-0 flex-1">
|
|
83
|
-
<p className="truncate text-sm font-medium">
|
|
91
|
+
<p className="truncate text-sm font-medium">
|
|
92
|
+
{machineLabel(host, browser.hostId)}
|
|
93
|
+
</p>
|
|
84
94
|
<p className="text-muted-foreground truncate text-xs">
|
|
85
95
|
{isLocal
|
|
86
|
-
? "
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
? [machineDetail(host), "serves this dashboard"]
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
.join(" · ")
|
|
99
|
+
: [
|
|
100
|
+
machineDetail(host),
|
|
101
|
+
viaConnect
|
|
102
|
+
? connectLine(host)
|
|
103
|
+
: host.linked
|
|
104
|
+
? `${host.ssh_target} · tasks run on its AgentOS`
|
|
105
|
+
: host.ssh_target,
|
|
106
|
+
]
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
.join(" · ")}
|
|
92
109
|
</p>
|
|
93
110
|
</div>
|
|
111
|
+
<Button
|
|
112
|
+
variant="ghost"
|
|
113
|
+
size="icon-sm"
|
|
114
|
+
className="h-11 w-11 sm:h-8 sm:w-8"
|
|
115
|
+
onClick={() => setRenaming(true)}
|
|
116
|
+
disabled={renaming}
|
|
117
|
+
aria-label={`Rename ${host.name}`}
|
|
118
|
+
title="Give it a name of your own"
|
|
119
|
+
>
|
|
120
|
+
<Pencil className="h-4 w-4" />
|
|
121
|
+
</Button>
|
|
122
|
+
<Button
|
|
123
|
+
variant="ghost"
|
|
124
|
+
size="icon-sm"
|
|
125
|
+
className={cn("h-11 w-11 sm:h-8 sm:w-8", here && "text-primary")}
|
|
126
|
+
aria-pressed={here}
|
|
127
|
+
onClick={() => browser.set(here ? null : host.id)}
|
|
128
|
+
aria-label={
|
|
129
|
+
here
|
|
130
|
+
? `This browser is not on ${host.name}`
|
|
131
|
+
: `This browser is on ${host.name}`
|
|
132
|
+
}
|
|
133
|
+
title={
|
|
134
|
+
here
|
|
135
|
+
? browser.detected
|
|
136
|
+
? "Detected from the connection. Tap to say otherwise"
|
|
137
|
+
: "You said you're here. Tap to undo"
|
|
138
|
+
: "Say this is the machine you're using now"
|
|
139
|
+
}
|
|
140
|
+
>
|
|
141
|
+
<MapPin className="h-4 w-4" />
|
|
142
|
+
</Button>
|
|
94
143
|
{!isLocal && (
|
|
95
144
|
<>
|
|
96
145
|
{!viaConnect && (
|
|
@@ -171,6 +220,7 @@ export function HostRow({ host }: { host: Host }) {
|
|
|
171
220
|
</>
|
|
172
221
|
)}
|
|
173
222
|
</div>
|
|
223
|
+
{renaming && <RenameHost host={host} onDone={() => setRenaming(false)} />}
|
|
174
224
|
{result && (
|
|
175
225
|
<p
|
|
176
226
|
className={cn(
|