@saadnvd1/agent-os 0.3.782 → 0.3.790

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/app/api/hosts/[id]/agents/route.ts +6 -4
  2. package/app/api/machine/route.ts +5 -3
  3. package/app/api/whats-new/route.ts +47 -0
  4. package/components/Chat/ChatPanel.tsx +2 -0
  5. package/components/Chat/ModelPicker.tsx +82 -42
  6. package/components/Chat/composer/ComposerBody.tsx +3 -0
  7. package/components/Chat/composer/Toolbar.tsx +3 -0
  8. package/components/Draft/AgentModelChip.test.ts +1 -0
  9. package/components/Draft/AgentModelChip.tsx +36 -93
  10. package/components/Draft/Chip.tsx +27 -0
  11. package/components/Draft/DraftChips.tsx +5 -1
  12. package/components/Draft/DraftPanel.tsx +0 -21
  13. package/components/Draft/catalogBlocker.ts +19 -0
  14. package/components/Draft/picker/AgentModelPicker.tsx +152 -0
  15. package/components/Draft/picker/AgentRail.tsx +145 -0
  16. package/components/Draft/picker/ModelList.tsx +136 -0
  17. package/components/Draft/picker/ModelNote.tsx +15 -0
  18. package/components/Draft/picker/PickerSheet.tsx +61 -0
  19. package/components/Draft/picker/SameAsLast.tsx +52 -0
  20. package/components/Draft/picker/SearchField.tsx +48 -0
  21. package/components/Draft/picker/picker.test.ts +152 -0
  22. package/components/Draft/picker/sections.test.ts +115 -0
  23. package/components/Draft/picker/sections.ts +121 -0
  24. package/components/Draft/picker/useListKeys.ts +54 -0
  25. package/components/Draft/picker/useModelSearch.ts +66 -0
  26. package/components/SidebarFooter.tsx +5 -1
  27. package/components/Updates/UpdateBanner.test.ts +1 -1
  28. package/components/Updates/UpdateBanner.tsx +28 -14
  29. package/components/WhatsNew/WhatsNewButton.tsx +33 -0
  30. package/components/WhatsNew/WhatsNewDialog.tsx +169 -0
  31. package/components/WhatsNew/WhatsNewHost.tsx +17 -0
  32. package/components/WhatsNew/WhatsNewRows.tsx +62 -0
  33. package/components/WhatsNew/index.ts +2 -0
  34. package/components/ui/dialog.tsx +12 -1
  35. package/content/whats-new.json +1782 -0
  36. package/data/chat/useChat.ts +3 -0
  37. package/data/machines/index.ts +13 -4
  38. package/data/machines/keys.ts +2 -1
  39. package/data/whats-new/index.ts +44 -0
  40. package/hooks/useWhatsNewSeen.ts +50 -0
  41. package/lib/chat/drivers/copilot.ts +9 -6
  42. package/lib/chat/drivers/opencode.ts +1 -1
  43. package/lib/chat/drivers/pi.ts +1 -1
  44. package/lib/chat/events.ts +6 -0
  45. package/lib/chat/settings.ts +2 -0
  46. package/lib/machines/catalog.ts +5 -1
  47. package/lib/machines/recent.ts +31 -0
  48. package/lib/machines/remote.ts +28 -9
  49. package/lib/machines/report.ts +7 -2
  50. package/lib/machines/types.ts +12 -0
  51. package/lib/orchestrator/review-prompt.ts +2 -0
  52. package/lib/security/demo.ts +2 -0
  53. package/lib/tasks/brief.ts +1 -0
  54. package/lib/whats-new/ahead.ts +28 -0
  55. package/lib/whats-new/areas.ts +76 -0
  56. package/lib/whats-new/claude.ts +35 -0
  57. package/lib/whats-new/collect.ts +68 -0
  58. package/lib/whats-new/generate.ts +107 -0
  59. package/lib/whats-new/lint.ts +76 -0
  60. package/lib/whats-new/load.ts +59 -0
  61. package/lib/whats-new/rows.ts +79 -0
  62. package/lib/whats-new/schema.ts +41 -0
  63. package/lib/whats-new/summarize.ts +91 -0
  64. package/lib/whats-new/unread.ts +10 -0
  65. package/package.json +2 -1
  66. package/scripts/autodeploy +33 -10
  67. package/scripts/autodeploy.test.ts +92 -2
  68. package/scripts/publish-detached +20 -0
  69. package/scripts/whats-new.ts +118 -0
  70. package/stores/whatsNew.ts +16 -0
  71. package/components/Draft/draftModels.test.ts +0 -31
  72. 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&apos;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 &ldquo;
99
+ {query.trim()}&rdquo;.
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
+ }