@saadnvd1/agent-os 0.3.782 → 0.3.786
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/api/hosts/[id]/agents/route.ts +6 -4
- package/app/api/machine/route.ts +5 -3
- package/app/api/whats-new/route.ts +47 -0
- package/components/Chat/ChatPanel.tsx +2 -0
- package/components/Chat/ModelPicker.tsx +82 -42
- package/components/Chat/composer/ComposerBody.tsx +3 -0
- package/components/Chat/composer/Toolbar.tsx +3 -0
- package/components/Draft/AgentModelChip.test.ts +1 -0
- package/components/Draft/AgentModelChip.tsx +36 -93
- package/components/Draft/Chip.tsx +27 -0
- package/components/Draft/DraftChips.tsx +5 -1
- package/components/Draft/DraftPanel.tsx +0 -21
- package/components/Draft/catalogBlocker.ts +19 -0
- package/components/Draft/picker/AgentModelPicker.tsx +152 -0
- package/components/Draft/picker/AgentRail.tsx +145 -0
- package/components/Draft/picker/ModelList.tsx +136 -0
- package/components/Draft/picker/ModelNote.tsx +15 -0
- package/components/Draft/picker/PickerSheet.tsx +61 -0
- package/components/Draft/picker/SameAsLast.tsx +52 -0
- package/components/Draft/picker/SearchField.tsx +48 -0
- package/components/Draft/picker/picker.test.ts +152 -0
- package/components/Draft/picker/sections.test.ts +115 -0
- package/components/Draft/picker/sections.ts +121 -0
- package/components/Draft/picker/useListKeys.ts +54 -0
- package/components/Draft/picker/useModelSearch.ts +66 -0
- package/components/SidebarFooter.tsx +3 -1
- package/components/Updates/UpdateBanner.test.ts +1 -1
- package/components/Updates/UpdateBanner.tsx +27 -13
- package/components/WhatsNew/WhatsNewButton.tsx +36 -0
- package/components/WhatsNew/WhatsNewDialog.tsx +169 -0
- package/components/WhatsNew/WhatsNewHost.tsx +17 -0
- package/components/WhatsNew/WhatsNewRows.tsx +62 -0
- package/components/WhatsNew/index.ts +2 -0
- package/components/ui/dialog.tsx +12 -1
- package/content/whats-new.json +1773 -0
- package/data/chat/useChat.ts +3 -0
- package/data/machines/index.ts +13 -4
- package/data/machines/keys.ts +2 -1
- package/data/whats-new/index.ts +44 -0
- package/hooks/useWhatsNewSeen.ts +50 -0
- package/lib/chat/drivers/copilot.ts +9 -6
- package/lib/chat/drivers/opencode.ts +1 -1
- package/lib/chat/drivers/pi.ts +1 -1
- package/lib/chat/events.ts +6 -0
- package/lib/chat/settings.ts +2 -0
- package/lib/machines/catalog.ts +5 -1
- package/lib/machines/recent.ts +31 -0
- package/lib/machines/remote.ts +28 -9
- package/lib/machines/report.ts +7 -2
- package/lib/machines/types.ts +12 -0
- package/lib/orchestrator/review-prompt.ts +2 -0
- package/lib/security/demo.ts +2 -0
- package/lib/tasks/brief.ts +1 -0
- package/lib/whats-new/ahead.ts +28 -0
- package/lib/whats-new/areas.ts +76 -0
- package/lib/whats-new/claude.ts +35 -0
- package/lib/whats-new/collect.ts +68 -0
- package/lib/whats-new/generate.ts +107 -0
- package/lib/whats-new/lint.ts +69 -0
- package/lib/whats-new/load.ts +59 -0
- package/lib/whats-new/rows.ts +79 -0
- package/lib/whats-new/schema.ts +41 -0
- package/lib/whats-new/summarize.ts +91 -0
- package/lib/whats-new/unread.ts +14 -0
- package/package.json +2 -1
- package/scripts/autodeploy +28 -0
- package/scripts/autodeploy.test.ts +53 -0
- package/scripts/whats-new.ts +118 -0
- package/stores/whatsNew.ts +16 -0
- package/components/Draft/draftModels.test.ts +0 -31
- package/components/Draft/draftModels.ts +0 -17
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import type { CatalogAgent, MachineCatalog } from "@/lib/machines/types";
|
|
5
|
+
import type { AgentType } from "@/lib/providers";
|
|
6
|
+
import { catalogBlocker } from "../catalogBlocker";
|
|
7
|
+
import { AgentRail, AgentStrip } from "./AgentRail";
|
|
8
|
+
import { ModelList } from "./ModelList";
|
|
9
|
+
import { PickerSheet } from "./PickerSheet";
|
|
10
|
+
import { SameAsLast, lastPick } from "./SameAsLast";
|
|
11
|
+
import { SearchField } from "./SearchField";
|
|
12
|
+
import { focusSelectedRow, useListKeys } from "./useListKeys";
|
|
13
|
+
import { useModelSearch } from "./useModelSearch";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Pick the agent, then only its models: a rail of agents beside the list on
|
|
17
|
+
* a desktop, a strip above it on a phone. "Same as last time" sets both.
|
|
18
|
+
*/
|
|
19
|
+
export function AgentModelPicker({
|
|
20
|
+
open,
|
|
21
|
+
onOpenChange,
|
|
22
|
+
catalog,
|
|
23
|
+
value,
|
|
24
|
+
projectName,
|
|
25
|
+
onPick,
|
|
26
|
+
}: {
|
|
27
|
+
open: boolean;
|
|
28
|
+
onOpenChange: (open: boolean) => void;
|
|
29
|
+
catalog: MachineCatalog;
|
|
30
|
+
value: { agent: AgentType; model: string };
|
|
31
|
+
projectName: string | null;
|
|
32
|
+
onPick: (agent: AgentType, model: string) => void;
|
|
33
|
+
}) {
|
|
34
|
+
const ready = catalog.agents.filter((a) => !catalogBlocker(a));
|
|
35
|
+
const [browsed, setBrowsed] = useState<AgentType>(value.agent);
|
|
36
|
+
const browsing: CatalogAgent | undefined =
|
|
37
|
+
ready.find((a) => a.agent === browsed) ?? ready[0];
|
|
38
|
+
const root = useRef<HTMLDivElement>(null);
|
|
39
|
+
const search = useRef<HTMLInputElement>(null);
|
|
40
|
+
|
|
41
|
+
const { query, setQuery, sections, elsewhere } = useModelSearch({
|
|
42
|
+
agent: {
|
|
43
|
+
agent: browsing?.agent ?? "",
|
|
44
|
+
label: browsing?.label ?? "",
|
|
45
|
+
models: browsing?.models ?? [],
|
|
46
|
+
},
|
|
47
|
+
agents: ready,
|
|
48
|
+
defaultValue: browsing?.defaultModel,
|
|
49
|
+
lastValue: catalog.recent.find((r) => r.agent === browsing?.agent)?.model,
|
|
50
|
+
open,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (open) setBrowsed(value.agent);
|
|
55
|
+
// Only on opening: a pick changes `value` while it closes.
|
|
56
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
57
|
+
}, [open]);
|
|
58
|
+
|
|
59
|
+
// After ←/→ the keys belong to the new agent's list.
|
|
60
|
+
const refocus = useRef(false);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!refocus.current) return;
|
|
63
|
+
refocus.current = false;
|
|
64
|
+
focusSelectedRow(root.current);
|
|
65
|
+
}, [browsing?.agent]);
|
|
66
|
+
|
|
67
|
+
const browse = (agent: AgentType, keepQuery = false) => {
|
|
68
|
+
setBrowsed(agent);
|
|
69
|
+
if (!keepQuery) setQuery("");
|
|
70
|
+
};
|
|
71
|
+
const step = (delta: 1 | -1) => {
|
|
72
|
+
const at = ready.findIndex((a) => a.agent === browsing?.agent);
|
|
73
|
+
const next = ready[(at + delta + ready.length) % ready.length];
|
|
74
|
+
if (next) {
|
|
75
|
+
refocus.current = next.agent !== browsing?.agent;
|
|
76
|
+
browse(next.agent);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const onKeyDown = useListKeys(root, search, step);
|
|
80
|
+
const choose = (agent: AgentType, model: string) => {
|
|
81
|
+
onPick(agent, model);
|
|
82
|
+
onOpenChange(false);
|
|
83
|
+
};
|
|
84
|
+
const same = lastPick(catalog);
|
|
85
|
+
const lastAgent = catalog.recent[0]?.agent;
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<PickerSheet
|
|
89
|
+
open={open}
|
|
90
|
+
onOpenChange={onOpenChange}
|
|
91
|
+
title="Agent & model"
|
|
92
|
+
onKeyDown={onKeyDown}
|
|
93
|
+
onOpened={() => focusSelectedRow(root.current)}
|
|
94
|
+
>
|
|
95
|
+
<div ref={root} className="flex min-h-0 flex-1 flex-col">
|
|
96
|
+
{same && !query && projectName && (
|
|
97
|
+
<SameAsLast pick={same} projectName={projectName} onPick={choose} />
|
|
98
|
+
)}
|
|
99
|
+
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
|
100
|
+
<div className="hidden md:block">
|
|
101
|
+
<AgentRail
|
|
102
|
+
agents={catalog.agents}
|
|
103
|
+
browsing={browsing?.agent ?? value.agent}
|
|
104
|
+
onBrowse={(a) => browse(a)}
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
<div className="md:hidden">
|
|
108
|
+
<AgentStrip
|
|
109
|
+
agents={catalog.agents}
|
|
110
|
+
browsing={browsing?.agent ?? value.agent}
|
|
111
|
+
lastAgent={lastAgent}
|
|
112
|
+
onBrowse={(a) => browse(a)}
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
116
|
+
<SearchField
|
|
117
|
+
value={query}
|
|
118
|
+
onChange={setQuery}
|
|
119
|
+
placeholder={`Search ${browsing?.label ?? "agent"} models`}
|
|
120
|
+
inputRef={search}
|
|
121
|
+
/>
|
|
122
|
+
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
|
123
|
+
{browsing && (
|
|
124
|
+
<ModelList
|
|
125
|
+
sections={sections}
|
|
126
|
+
selected={browsing.agent === value.agent ? value.model : null}
|
|
127
|
+
query={query}
|
|
128
|
+
agentLabel={browsing.label}
|
|
129
|
+
elsewhere={elsewhere}
|
|
130
|
+
onPick={(model) => choose(browsing.agent, model)}
|
|
131
|
+
onJump={(a) => browse(a as AgentType, true)}
|
|
132
|
+
/>
|
|
133
|
+
)}
|
|
134
|
+
{browsing?.modelsError && (
|
|
135
|
+
<p className="text-muted-foreground px-3 py-2 text-xs">
|
|
136
|
+
Couldn't list its models: {browsing.modelsError}
|
|
137
|
+
</p>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
<p className="text-muted-foreground bg-foreground/[0.03] hidden shrink-0 gap-3 px-3 py-1.5 text-[11px] md:flex">
|
|
143
|
+
<span>←→ agent</span>
|
|
144
|
+
<span>↑↓ model</span>
|
|
145
|
+
<span>⏎ select</span>
|
|
146
|
+
<span>/ search</span>
|
|
147
|
+
<span>esc close</span>
|
|
148
|
+
</p>
|
|
149
|
+
</div>
|
|
150
|
+
</PickerSheet>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { ChevronDown } from "lucide-react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import type { CatalogAgent } from "@/lib/machines/types";
|
|
7
|
+
import type { AgentType } from "@/lib/providers";
|
|
8
|
+
import { catalogBlocker } from "../catalogBlocker";
|
|
9
|
+
|
|
10
|
+
const initials = (label: string) => {
|
|
11
|
+
const w = label.split(/\s+/);
|
|
12
|
+
return (w.length > 1 ? w[0][0] + w[1][0] : label.slice(0, 2)).toUpperCase();
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const Mark = ({ label }: { label: string }) => (
|
|
16
|
+
<span className="bg-muted text-muted-foreground grid h-6 w-6 shrink-0 place-items-center rounded-md text-[10px] font-semibold">
|
|
17
|
+
{initials(label)}
|
|
18
|
+
</span>
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const Dot = ({ login }: { login: boolean }) => (
|
|
22
|
+
<i
|
|
23
|
+
className={cn(
|
|
24
|
+
"h-1.5 w-1.5 shrink-0 rounded-full",
|
|
25
|
+
login ? "bg-amber-500" : "bg-muted-foreground/40"
|
|
26
|
+
)}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
/** Agents down the left on a desktop, with how many models each has. */
|
|
31
|
+
export function AgentRail({
|
|
32
|
+
agents,
|
|
33
|
+
browsing,
|
|
34
|
+
onBrowse,
|
|
35
|
+
}: {
|
|
36
|
+
agents: CatalogAgent[];
|
|
37
|
+
browsing: AgentType;
|
|
38
|
+
onBrowse: (agent: AgentType) => void;
|
|
39
|
+
}) {
|
|
40
|
+
const [more, setMore] = useState(false);
|
|
41
|
+
const ready = agents.filter((a) => !catalogBlocker(a));
|
|
42
|
+
const other = agents.filter((a) => catalogBlocker(a));
|
|
43
|
+
return (
|
|
44
|
+
<nav
|
|
45
|
+
aria-label="Agents"
|
|
46
|
+
className="flex w-44 shrink-0 flex-col gap-0.5 overflow-y-auto p-1.5"
|
|
47
|
+
>
|
|
48
|
+
{ready.map((a) => (
|
|
49
|
+
<button
|
|
50
|
+
key={a.agent}
|
|
51
|
+
type="button"
|
|
52
|
+
aria-current={a.agent === browsing}
|
|
53
|
+
onClick={() => onBrowse(a.agent)}
|
|
54
|
+
className={cn(
|
|
55
|
+
"hover:bg-foreground/[0.05] flex h-10 items-center gap-2 rounded-lg px-2 text-left text-sm",
|
|
56
|
+
a.agent === browsing && "bg-primary/10 font-medium"
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
<Mark label={a.label} />
|
|
60
|
+
<span className="min-w-0 flex-1 truncate">{a.label}</span>
|
|
61
|
+
<span className="text-muted-foreground text-xs">
|
|
62
|
+
{a.models.length || "–"}
|
|
63
|
+
</span>
|
|
64
|
+
</button>
|
|
65
|
+
))}
|
|
66
|
+
{other.length > 0 && (
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
aria-expanded={more}
|
|
70
|
+
onClick={() => setMore(!more)}
|
|
71
|
+
className="text-muted-foreground hover:text-foreground flex h-9 items-center justify-between rounded-lg px-2 text-[11px] font-medium"
|
|
72
|
+
>
|
|
73
|
+
Not installed · {other.length}
|
|
74
|
+
<ChevronDown
|
|
75
|
+
className={cn("h-3 w-3 transition-transform", more && "rotate-180")}
|
|
76
|
+
/>
|
|
77
|
+
</button>
|
|
78
|
+
)}
|
|
79
|
+
{more &&
|
|
80
|
+
other.map((a) => (
|
|
81
|
+
<div
|
|
82
|
+
key={a.agent}
|
|
83
|
+
className="text-muted-foreground flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm opacity-70"
|
|
84
|
+
>
|
|
85
|
+
<Mark label={a.label} />
|
|
86
|
+
<span className="flex min-w-0 flex-1 flex-col">
|
|
87
|
+
<span className="truncate">{a.label}</span>
|
|
88
|
+
<span className="truncate text-[11px]">{catalogBlocker(a)}</span>
|
|
89
|
+
</span>
|
|
90
|
+
<Dot login={a.auth === "needs-login"} />
|
|
91
|
+
</div>
|
|
92
|
+
))}
|
|
93
|
+
</nav>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The same agents as a row to swipe along, for a phone. */
|
|
98
|
+
export function AgentStrip({
|
|
99
|
+
agents,
|
|
100
|
+
browsing,
|
|
101
|
+
lastAgent,
|
|
102
|
+
onBrowse,
|
|
103
|
+
}: {
|
|
104
|
+
agents: CatalogAgent[];
|
|
105
|
+
browsing: AgentType;
|
|
106
|
+
lastAgent?: AgentType;
|
|
107
|
+
onBrowse: (agent: AgentType) => void;
|
|
108
|
+
}) {
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
aria-label="Agents"
|
|
112
|
+
className="flex shrink-0 [scrollbar-width:none] gap-1.5 overflow-x-auto px-3 py-1.5"
|
|
113
|
+
>
|
|
114
|
+
{agents.map((a) => {
|
|
115
|
+
const blocked = catalogBlocker(a);
|
|
116
|
+
return blocked ? (
|
|
117
|
+
<span
|
|
118
|
+
key={a.agent}
|
|
119
|
+
className="text-muted-foreground flex h-11 shrink-0 items-center gap-1.5 rounded-lg px-3 text-sm opacity-60"
|
|
120
|
+
>
|
|
121
|
+
<Dot login={a.auth === "needs-login"} />
|
|
122
|
+
{a.label}
|
|
123
|
+
<span className="text-[11px]">{blocked}</span>
|
|
124
|
+
</span>
|
|
125
|
+
) : (
|
|
126
|
+
<button
|
|
127
|
+
key={a.agent}
|
|
128
|
+
type="button"
|
|
129
|
+
aria-pressed={a.agent === browsing}
|
|
130
|
+
onClick={() => onBrowse(a.agent)}
|
|
131
|
+
className={cn(
|
|
132
|
+
"bg-foreground/[0.05] flex h-11 shrink-0 items-center gap-1.5 rounded-lg px-3 text-sm",
|
|
133
|
+
a.agent === browsing && "bg-primary/15 text-primary font-medium"
|
|
134
|
+
)}
|
|
135
|
+
>
|
|
136
|
+
{a.label}
|
|
137
|
+
{a.agent === lastAgent && (
|
|
138
|
+
<i className="bg-primary h-1.5 w-1.5 rounded-full" />
|
|
139
|
+
)}
|
|
140
|
+
</button>
|
|
141
|
+
);
|
|
142
|
+
})}
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Check, Clock } from "lucide-react";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
import type { ChatModel } from "@/lib/chat/events";
|
|
6
|
+
import { ModelNote } from "./ModelNote";
|
|
7
|
+
import type { Elsewhere, Section } from "./sections";
|
|
8
|
+
|
|
9
|
+
// The query's words picked out in a name.
|
|
10
|
+
function Highlight({ text, query }: { text: string; query: string }) {
|
|
11
|
+
const words = query.trim().split(/\s+/).filter(Boolean);
|
|
12
|
+
if (!words.length) return <>{text}</>;
|
|
13
|
+
const re = new RegExp(
|
|
14
|
+
`(${words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`,
|
|
15
|
+
"ig"
|
|
16
|
+
);
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
{text.split(re).map((part, i) =>
|
|
20
|
+
i % 2 ? (
|
|
21
|
+
<mark key={i} className="text-foreground bg-primary/25 rounded-sm">
|
|
22
|
+
{part}
|
|
23
|
+
</mark>
|
|
24
|
+
) : (
|
|
25
|
+
part
|
|
26
|
+
)
|
|
27
|
+
)}
|
|
28
|
+
</>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function Row({
|
|
33
|
+
model,
|
|
34
|
+
selected,
|
|
35
|
+
query,
|
|
36
|
+
onPick,
|
|
37
|
+
}: {
|
|
38
|
+
model: ChatModel;
|
|
39
|
+
selected: boolean;
|
|
40
|
+
query: string;
|
|
41
|
+
onPick: (value: string) => void;
|
|
42
|
+
}) {
|
|
43
|
+
const sub = model.disabled ?? model.description;
|
|
44
|
+
return (
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
data-pick-row
|
|
48
|
+
data-selected={selected || undefined}
|
|
49
|
+
disabled={!!model.disabled}
|
|
50
|
+
onClick={() => onPick(model.value)}
|
|
51
|
+
className={cn(
|
|
52
|
+
"hover:bg-foreground/[0.05] focus-visible:bg-foreground/[0.07] flex min-h-11 w-full items-center gap-2 rounded-lg px-2 text-left outline-none disabled:opacity-50 md:min-h-9",
|
|
53
|
+
selected && "bg-primary/10"
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
56
|
+
<Check
|
|
57
|
+
className={cn(
|
|
58
|
+
"text-primary h-3.5 w-3.5 shrink-0",
|
|
59
|
+
selected ? "opacity-100" : "opacity-0"
|
|
60
|
+
)}
|
|
61
|
+
/>
|
|
62
|
+
<span className="flex min-w-0 flex-1 flex-col py-1">
|
|
63
|
+
<span className="truncate text-sm">
|
|
64
|
+
<Highlight text={model.label} query={query} />
|
|
65
|
+
</span>
|
|
66
|
+
{sub && (
|
|
67
|
+
<span className="text-muted-foreground line-clamp-2 text-xs">
|
|
68
|
+
{sub}
|
|
69
|
+
</span>
|
|
70
|
+
)}
|
|
71
|
+
</span>
|
|
72
|
+
{model.note && <ModelNote note={model.note} />}
|
|
73
|
+
</button>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The models of one agent in sections, the chosen one ticked. */
|
|
78
|
+
export function ModelList({
|
|
79
|
+
sections,
|
|
80
|
+
selected,
|
|
81
|
+
query,
|
|
82
|
+
agentLabel,
|
|
83
|
+
elsewhere,
|
|
84
|
+
onPick,
|
|
85
|
+
onJump,
|
|
86
|
+
}: {
|
|
87
|
+
sections: Section[];
|
|
88
|
+
selected: string | null;
|
|
89
|
+
query: string;
|
|
90
|
+
agentLabel: string;
|
|
91
|
+
elsewhere?: Elsewhere | null;
|
|
92
|
+
onPick: (value: string) => void;
|
|
93
|
+
onJump?: (agent: string) => void;
|
|
94
|
+
}) {
|
|
95
|
+
if (sections.length === 0)
|
|
96
|
+
return (
|
|
97
|
+
<p className="text-muted-foreground px-3 py-6 text-center text-sm">
|
|
98
|
+
No {agentLabel ? `${agentLabel} ` : ""}model matches “
|
|
99
|
+
{query.trim()}”.
|
|
100
|
+
{elsewhere && onJump && (
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
data-pick-row
|
|
104
|
+
onClick={() => onJump(elsewhere.agent)}
|
|
105
|
+
className="text-primary mt-2 block min-h-11 w-full font-medium hover:underline"
|
|
106
|
+
>
|
|
107
|
+
{elsewhere.count} match in other agents
|
|
108
|
+
</button>
|
|
109
|
+
)}
|
|
110
|
+
</p>
|
|
111
|
+
);
|
|
112
|
+
return (
|
|
113
|
+
<div data-model-list className="flex flex-col gap-0.5 p-1.5">
|
|
114
|
+
{sections.map((s) => (
|
|
115
|
+
<div key={s.key} className="flex flex-col gap-0.5">
|
|
116
|
+
<div className="text-muted-foreground flex items-center justify-between px-2 pt-2 pb-1 text-[11px] font-medium">
|
|
117
|
+
<span className="flex items-center gap-1">
|
|
118
|
+
{s.kind === "last" && <Clock className="h-3 w-3" />}
|
|
119
|
+
{s.heading}
|
|
120
|
+
</span>
|
|
121
|
+
{s.kind === "group" && <span>{s.models.length}</span>}
|
|
122
|
+
</div>
|
|
123
|
+
{s.models.map((m) => (
|
|
124
|
+
<Row
|
|
125
|
+
key={m.value}
|
|
126
|
+
model={m}
|
|
127
|
+
selected={m.value === selected}
|
|
128
|
+
query={query}
|
|
129
|
+
onPick={onPick}
|
|
130
|
+
/>
|
|
131
|
+
))}
|
|
132
|
+
</div>
|
|
133
|
+
))}
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { StatusPill } from "@/components/ui/status-pill";
|
|
2
|
+
|
|
3
|
+
// "15x usage" and up costs real quota; "Included" costs none.
|
|
4
|
+
export function noteTone(note: string): "amber" | "green" | "muted" {
|
|
5
|
+
if (/included/i.test(note)) return "green";
|
|
6
|
+
return parseFloat(note) >= 10 ? "amber" : "muted";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function ModelNote({ note }: { note: string }) {
|
|
10
|
+
return (
|
|
11
|
+
<StatusPill tone={noteTone(note)} className="shrink-0">
|
|
12
|
+
{note}
|
|
13
|
+
</StatusPill>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
5
|
+
import { X } from "lucide-react";
|
|
6
|
+
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
|
7
|
+
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
|
8
|
+
import { cn } from "@/lib/utils";
|
|
9
|
+
|
|
10
|
+
// Where the phone layout ends and the desktop popover begins.
|
|
11
|
+
export const PICKER_DESKTOP = "(min-width: 768px)";
|
|
12
|
+
|
|
13
|
+
/** A bottom sheet on a phone, a dialog on a desktop. */
|
|
14
|
+
export function PickerSheet({
|
|
15
|
+
open,
|
|
16
|
+
onOpenChange,
|
|
17
|
+
title,
|
|
18
|
+
onKeyDown,
|
|
19
|
+
onOpened,
|
|
20
|
+
children,
|
|
21
|
+
}: {
|
|
22
|
+
open: boolean;
|
|
23
|
+
onOpenChange: (open: boolean) => void;
|
|
24
|
+
title: string;
|
|
25
|
+
onKeyDown?: React.KeyboardEventHandler;
|
|
26
|
+
// Desktop only: a phone keeps the keyboard down until asked.
|
|
27
|
+
onOpened?: () => void;
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
}) {
|
|
30
|
+
const desktop = useMediaQuery(PICKER_DESKTOP);
|
|
31
|
+
return (
|
|
32
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
33
|
+
<DialogContent
|
|
34
|
+
showCloseButton={false}
|
|
35
|
+
placement={desktop ? "center" : "sheet"}
|
|
36
|
+
aria-describedby={undefined}
|
|
37
|
+
aria-label={title}
|
|
38
|
+
onKeyDown={onKeyDown}
|
|
39
|
+
onOpenAutoFocus={(e) => {
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
if (desktop) onOpened?.();
|
|
42
|
+
}}
|
|
43
|
+
className={cn(
|
|
44
|
+
"gap-0 overflow-hidden p-0",
|
|
45
|
+
desktop ? "h-[440px] max-w-[620px] sm:max-w-[620px]" : "h-[86dvh]"
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
<div className="flex h-12 shrink-0 items-center justify-between px-4 md:hidden">
|
|
49
|
+
<DialogTitle className="text-base">{title}</DialogTitle>
|
|
50
|
+
<DialogPrimitive.Close
|
|
51
|
+
aria-label="Close"
|
|
52
|
+
className="text-muted-foreground hover:text-foreground -mr-2 grid h-11 w-11 place-items-center rounded-lg"
|
|
53
|
+
>
|
|
54
|
+
<X className="h-4 w-4" />
|
|
55
|
+
</DialogPrimitive.Close>
|
|
56
|
+
</div>
|
|
57
|
+
{children}
|
|
58
|
+
</DialogContent>
|
|
59
|
+
</Dialog>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Repeat } from "lucide-react";
|
|
4
|
+
import type { CatalogAgent, MachineCatalog } from "@/lib/machines/types";
|
|
5
|
+
import { catalogBlocker } from "../catalogBlocker";
|
|
6
|
+
import { ModelNote } from "./ModelNote";
|
|
7
|
+
|
|
8
|
+
export interface LastPick {
|
|
9
|
+
agent: CatalogAgent;
|
|
10
|
+
model: CatalogAgent["models"][number];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The newest pick in the project that this machine can still run. */
|
|
14
|
+
export function lastPick(catalog: MachineCatalog): LastPick | null {
|
|
15
|
+
for (const r of catalog.recent) {
|
|
16
|
+
const agent = catalog.agents.find((a) => a.agent === r.agent);
|
|
17
|
+
const model = agent?.models.find((m) => m.value === r.model);
|
|
18
|
+
if (agent && model && !catalogBlocker(agent)) return { agent, model };
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One tap to the agent and model the project last used. */
|
|
24
|
+
export function SameAsLast({
|
|
25
|
+
pick,
|
|
26
|
+
projectName,
|
|
27
|
+
onPick,
|
|
28
|
+
}: {
|
|
29
|
+
pick: LastPick;
|
|
30
|
+
projectName: string;
|
|
31
|
+
onPick: (agent: CatalogAgent["agent"], model: string) => void;
|
|
32
|
+
}) {
|
|
33
|
+
return (
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
data-pick-row
|
|
37
|
+
onClick={() => onPick(pick.agent.agent, pick.model.value)}
|
|
38
|
+
className="bg-primary/10 hover:bg-primary/15 mx-1.5 mt-1.5 flex min-h-11 shrink-0 items-center gap-2.5 rounded-lg px-3 text-left"
|
|
39
|
+
>
|
|
40
|
+
<Repeat className="text-primary h-4 w-4 shrink-0" />
|
|
41
|
+
<span className="flex min-w-0 flex-1 flex-col py-1">
|
|
42
|
+
<span className="truncate text-sm font-medium">
|
|
43
|
+
Same as last time in {projectName}
|
|
44
|
+
</span>
|
|
45
|
+
<span className="text-muted-foreground truncate text-xs">
|
|
46
|
+
{pick.agent.label} · {pick.model.label}
|
|
47
|
+
</span>
|
|
48
|
+
</span>
|
|
49
|
+
{pick.model.note && <ModelNote note={pick.model.note} />}
|
|
50
|
+
</button>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { Ref } from "react";
|
|
4
|
+
import { Search, X } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
export function SearchField({
|
|
7
|
+
value,
|
|
8
|
+
onChange,
|
|
9
|
+
placeholder,
|
|
10
|
+
inputRef,
|
|
11
|
+
}: {
|
|
12
|
+
value: string;
|
|
13
|
+
onChange: (v: string) => void;
|
|
14
|
+
placeholder: string;
|
|
15
|
+
inputRef: Ref<HTMLInputElement>;
|
|
16
|
+
}) {
|
|
17
|
+
return (
|
|
18
|
+
<label className="bg-input/60 hairline mx-1.5 mt-1.5 flex h-11 items-center gap-2 rounded-lg px-3 md:h-9">
|
|
19
|
+
<Search className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
|
20
|
+
<input
|
|
21
|
+
ref={inputRef}
|
|
22
|
+
value={value}
|
|
23
|
+
onChange={(e) => onChange(e.target.value)}
|
|
24
|
+
placeholder={placeholder}
|
|
25
|
+
autoComplete="off"
|
|
26
|
+
autoCorrect="off"
|
|
27
|
+
spellCheck={false}
|
|
28
|
+
enterKeyHint="search"
|
|
29
|
+
aria-label={placeholder}
|
|
30
|
+
className="placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent text-base outline-none md:text-sm"
|
|
31
|
+
/>
|
|
32
|
+
{value ? (
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
aria-label="Clear search"
|
|
36
|
+
onClick={() => onChange("")}
|
|
37
|
+
className="text-muted-foreground hover:text-foreground grid h-11 w-8 place-items-center md:h-8"
|
|
38
|
+
>
|
|
39
|
+
<X className="h-3.5 w-3.5" />
|
|
40
|
+
</button>
|
|
41
|
+
) : (
|
|
42
|
+
<kbd className="text-muted-foreground bg-muted hidden rounded px-1.5 text-[10px] md:block">
|
|
43
|
+
/
|
|
44
|
+
</kbd>
|
|
45
|
+
)}
|
|
46
|
+
</label>
|
|
47
|
+
);
|
|
48
|
+
}
|