@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
|
+
import "@/components/Chat/composer/test-dom";
|
|
2
|
+
import { act, createElement } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { currentModelValue, ModelPicker } from "@/components/Chat/ModelPicker";
|
|
6
|
+
import type { CatalogAgent, MachineCatalog } from "@/lib/machines/types";
|
|
7
|
+
import { AgentModelPicker } from "./AgentModelPicker";
|
|
8
|
+
|
|
9
|
+
// Radix dispatches CustomEvents; the node global is not jsdom's.
|
|
10
|
+
Object.defineProperty(globalThis, "CustomEvent", {
|
|
11
|
+
value: (window as unknown as { CustomEvent: unknown }).CustomEvent,
|
|
12
|
+
configurable: true,
|
|
13
|
+
});
|
|
14
|
+
// A desktop, so the dialog layout and its autofocus apply.
|
|
15
|
+
Object.defineProperty(window, "matchMedia", {
|
|
16
|
+
value: () => ({
|
|
17
|
+
matches: true,
|
|
18
|
+
addEventListener() {},
|
|
19
|
+
removeEventListener() {},
|
|
20
|
+
}),
|
|
21
|
+
configurable: true,
|
|
22
|
+
});
|
|
23
|
+
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
24
|
+
class RO {
|
|
25
|
+
observe() {}
|
|
26
|
+
unobserve() {}
|
|
27
|
+
disconnect() {}
|
|
28
|
+
}
|
|
29
|
+
(globalThis as Record<string, unknown>).ResizeObserver ??= RO;
|
|
30
|
+
|
|
31
|
+
const agent = (id: string, label: string, models: string[]): CatalogAgent => ({
|
|
32
|
+
agent: id as CatalogAgent["agent"],
|
|
33
|
+
label,
|
|
34
|
+
installed: true,
|
|
35
|
+
auth: "ok" as CatalogAgent["auth"],
|
|
36
|
+
hint: null,
|
|
37
|
+
models: models.map((m) => ({ value: m, label: m.toUpperCase() })),
|
|
38
|
+
defaultModel: models[0],
|
|
39
|
+
taskBlocked: null,
|
|
40
|
+
modelsError: null,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const catalog: MachineCatalog = {
|
|
44
|
+
agents: [
|
|
45
|
+
agent("claude", "Claude Code", ["opus", "sonnet"]),
|
|
46
|
+
agent("codex", "Codex", ["gpt-a", "gpt-flash"]),
|
|
47
|
+
],
|
|
48
|
+
ollama: [],
|
|
49
|
+
recent: [{ agent: "claude", model: "sonnet" }],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const press = async (key: string, target: Element) =>
|
|
53
|
+
act(async () => {
|
|
54
|
+
target.dispatchEvent(
|
|
55
|
+
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const roots: ReturnType<typeof createRoot>[] = [];
|
|
60
|
+
afterEach(async () => {
|
|
61
|
+
await act(async () => roots.splice(0).forEach((r) => r.unmount()));
|
|
62
|
+
document.body.innerHTML = "";
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
async function mount(node: () => ReturnType<typeof createElement>) {
|
|
66
|
+
const el = document.createElement("div");
|
|
67
|
+
document.body.appendChild(el);
|
|
68
|
+
const root = createRoot(el);
|
|
69
|
+
roots.push(root);
|
|
70
|
+
await act(async () => root.render(node()));
|
|
71
|
+
const rerender = (n: ReturnType<typeof createElement>) =>
|
|
72
|
+
act(async () => root.render(n));
|
|
73
|
+
return { rerender };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const agentPicker = (open: boolean) =>
|
|
77
|
+
createElement(AgentModelPicker, {
|
|
78
|
+
open,
|
|
79
|
+
onOpenChange: vi.fn(),
|
|
80
|
+
catalog,
|
|
81
|
+
value: { agent: "claude", model: "opus" },
|
|
82
|
+
projectName: "proj",
|
|
83
|
+
onPick: vi.fn(),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const search = () => document.querySelector("input") as HTMLInputElement | null;
|
|
87
|
+
|
|
88
|
+
describe("currentModelValue", () => {
|
|
89
|
+
const models = [{ value: "opus", label: "Opus" }];
|
|
90
|
+
it("maps a full Claude id to its family alias", () => {
|
|
91
|
+
expect(currentModelValue(models, "claude-opus-5-5")).toBe("opus");
|
|
92
|
+
expect(currentModelValue(models, "opus")).toBe("opus");
|
|
93
|
+
expect(currentModelValue(models, "claude-haiku-5-5")).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("ModelPicker", () => {
|
|
98
|
+
it("marks the chat's model and names the agent in the lock bar", async () => {
|
|
99
|
+
await mount(() =>
|
|
100
|
+
createElement(ModelPicker, {
|
|
101
|
+
models: [
|
|
102
|
+
{ value: "sonnet", label: "Sonnet" },
|
|
103
|
+
{ value: "opus", label: "Opus" },
|
|
104
|
+
],
|
|
105
|
+
model: "claude-opus-5-5",
|
|
106
|
+
agentName: "Claude Code",
|
|
107
|
+
open: true,
|
|
108
|
+
onOpenChange: vi.fn(),
|
|
109
|
+
onPick: vi.fn(),
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
const selected = document.querySelectorAll('[data-selected="true"]');
|
|
113
|
+
expect(selected).toHaveLength(1);
|
|
114
|
+
expect(selected[0].textContent).toContain("Opus");
|
|
115
|
+
expect(document.body.textContent).toContain(
|
|
116
|
+
"Claude Code · switching agent starts a new session"
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("AgentModelPicker", () => {
|
|
122
|
+
it("starts each opening with an empty search", async () => {
|
|
123
|
+
const { rerender } = await mount(() => agentPicker(true));
|
|
124
|
+
const input = search()!;
|
|
125
|
+
await act(async () => {
|
|
126
|
+
const set = Object.getOwnPropertyDescriptor(
|
|
127
|
+
HTMLInputElement.prototype,
|
|
128
|
+
"value"
|
|
129
|
+
)!.set!;
|
|
130
|
+
set.call(input, "flash");
|
|
131
|
+
input.dispatchEvent(
|
|
132
|
+
new (window as unknown as { Event: typeof Event }).Event("input", {
|
|
133
|
+
bubbles: true,
|
|
134
|
+
})
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
expect(search()!.value).toBe("flash");
|
|
138
|
+
await rerender(agentPicker(false));
|
|
139
|
+
await rerender(agentPicker(true));
|
|
140
|
+
expect(search()!.value).toBe("");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("puts focus on the new agent's model list after ←/→", async () => {
|
|
144
|
+
await mount(() => agentPicker(true));
|
|
145
|
+
const focused = () => document.activeElement as HTMLElement;
|
|
146
|
+
await press("ArrowRight", focused());
|
|
147
|
+
expect(focused().closest("[data-model-list]")).not.toBeNull();
|
|
148
|
+
expect(focused().textContent).toContain("GPT-A");
|
|
149
|
+
await press("ArrowDown", focused());
|
|
150
|
+
expect(focused().textContent).toContain("GPT-FLASH");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { ChatModel } from "@/lib/chat/events";
|
|
3
|
+
import { buildSections, matchesElsewhere, matchesQuery } from "./sections";
|
|
4
|
+
|
|
5
|
+
const m = (value: string, group?: string): ChatModel => ({
|
|
6
|
+
value,
|
|
7
|
+
label: value.toUpperCase(),
|
|
8
|
+
...(group ? { group } : {}),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const models = [
|
|
12
|
+
m("default"),
|
|
13
|
+
m("a1", "Alpha"),
|
|
14
|
+
m("a2", "Alpha"),
|
|
15
|
+
m("b1", "Beta"),
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
describe("buildSections", () => {
|
|
19
|
+
it("puts last used and the default on top, then groups without repeating them", () => {
|
|
20
|
+
const s = buildSections({
|
|
21
|
+
models,
|
|
22
|
+
agentLabel: "X",
|
|
23
|
+
defaultValue: "default",
|
|
24
|
+
lastValue: "a2",
|
|
25
|
+
query: "",
|
|
26
|
+
});
|
|
27
|
+
expect(s.map((x) => [x.heading, x.models.map((y) => y.value)])).toEqual([
|
|
28
|
+
["Last used", ["a2"]],
|
|
29
|
+
["Default", ["default"]],
|
|
30
|
+
["Alpha", ["a1"]],
|
|
31
|
+
["Beta", ["b1"]],
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("shows the default once when it is also the last used", () => {
|
|
36
|
+
const s = buildSections({
|
|
37
|
+
models,
|
|
38
|
+
agentLabel: "X",
|
|
39
|
+
defaultValue: "default",
|
|
40
|
+
lastValue: "default",
|
|
41
|
+
query: "",
|
|
42
|
+
});
|
|
43
|
+
expect(s.map((x) => x.kind)).toEqual(["last", "group", "group"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("filters to the matches, still grouped, and drops the top sections", () => {
|
|
47
|
+
const s = buildSections({
|
|
48
|
+
models,
|
|
49
|
+
agentLabel: "X",
|
|
50
|
+
defaultValue: "default",
|
|
51
|
+
lastValue: "a2",
|
|
52
|
+
query: "alpha a",
|
|
53
|
+
});
|
|
54
|
+
expect(s).toHaveLength(1);
|
|
55
|
+
expect(s[0].models.map((y) => y.value)).toEqual(["a1", "a2"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("is empty when nothing matches", () => {
|
|
59
|
+
expect(buildSections({ models, agentLabel: "X", query: "zzz" })).toEqual(
|
|
60
|
+
[]
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("lists ungrouped models under a plain heading", () => {
|
|
65
|
+
const s = buildSections({ models: [m("a")], agentLabel: "X", query: "" });
|
|
66
|
+
expect(s[0].heading).toBe("Models");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("matchesQuery", () => {
|
|
71
|
+
it("matches the agent's name as well as the model's", () => {
|
|
72
|
+
expect(matchesQuery(m("a1", "Alpha"), "Copilot", "copilot alpha")).toBe(
|
|
73
|
+
true
|
|
74
|
+
);
|
|
75
|
+
expect(matchesQuery(m("a1", "Alpha"), "Copilot", "beta")).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("matchesElsewhere", () => {
|
|
80
|
+
const agents = [
|
|
81
|
+
{ agent: "one", label: "One", models: [m("x")] },
|
|
82
|
+
{ agent: "two", label: "Two", models: [m("groq-a"), m("groq-b")] },
|
|
83
|
+
{ agent: "three", label: "Three", models: [m("groq-c")] },
|
|
84
|
+
];
|
|
85
|
+
it("counts matches in the other agents and names the first with any", () => {
|
|
86
|
+
expect(matchesElsewhere(agents, "one", "groq")).toEqual({
|
|
87
|
+
count: 3,
|
|
88
|
+
agent: "two",
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
it("is null with no query or no match", () => {
|
|
92
|
+
expect(matchesElsewhere(agents, "one", "")).toBeNull();
|
|
93
|
+
expect(matchesElsewhere(agents, "one", "nope")).toBeNull();
|
|
94
|
+
expect(matchesElsewhere(agents, "two", "groq-a")).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("buildSections current", () => {
|
|
99
|
+
it("pins the current model on top and does not repeat it", () => {
|
|
100
|
+
const s = buildSections({
|
|
101
|
+
models,
|
|
102
|
+
agentLabel: "X",
|
|
103
|
+
defaultValue: "default",
|
|
104
|
+
lastValue: "a1",
|
|
105
|
+
currentValue: "a1",
|
|
106
|
+
query: "",
|
|
107
|
+
});
|
|
108
|
+
expect(s.map((x) => [x.heading, x.models.map((y) => y.value)])).toEqual([
|
|
109
|
+
["Current", ["a1"]],
|
|
110
|
+
["Default", ["default"]],
|
|
111
|
+
["Alpha", ["a2"]],
|
|
112
|
+
["Beta", ["b1"]],
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { ChatModel } from "@/lib/chat/events";
|
|
2
|
+
|
|
3
|
+
export interface Section {
|
|
4
|
+
key: string;
|
|
5
|
+
kind: "current" | "last" | "default" | "group";
|
|
6
|
+
heading: string;
|
|
7
|
+
models: ChatModel[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const words = (q: string) => q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
11
|
+
|
|
12
|
+
/** Every word of the query appears in the model's name, group, id or agent. */
|
|
13
|
+
export function matchesQuery(
|
|
14
|
+
m: ChatModel,
|
|
15
|
+
agentLabel: string,
|
|
16
|
+
query: string
|
|
17
|
+
): boolean {
|
|
18
|
+
const hay =
|
|
19
|
+
`${agentLabel} ${m.label} ${m.group ?? ""} ${m.value}`.toLowerCase();
|
|
20
|
+
return words(query).every((w) => hay.includes(w));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The list a person scans: what they used last, the agent's default, then
|
|
25
|
+
* the rest under the provider that serves them. A search flattens it to the
|
|
26
|
+
* matches, still grouped.
|
|
27
|
+
*/
|
|
28
|
+
export function buildSections({
|
|
29
|
+
models,
|
|
30
|
+
agentLabel,
|
|
31
|
+
defaultValue,
|
|
32
|
+
lastValue,
|
|
33
|
+
currentValue,
|
|
34
|
+
query,
|
|
35
|
+
}: {
|
|
36
|
+
models: ChatModel[];
|
|
37
|
+
agentLabel: string;
|
|
38
|
+
defaultValue?: string;
|
|
39
|
+
lastValue?: string;
|
|
40
|
+
/** The model in use now: pinned on top as "Current". */
|
|
41
|
+
currentValue?: string;
|
|
42
|
+
query: string;
|
|
43
|
+
}): Section[] {
|
|
44
|
+
const searching = words(query).length > 0;
|
|
45
|
+
const sections: Section[] = [];
|
|
46
|
+
const taken = new Set<string>();
|
|
47
|
+
if (!searching) {
|
|
48
|
+
const current = models.find((m) => m.value === currentValue);
|
|
49
|
+
if (current) {
|
|
50
|
+
sections.push({
|
|
51
|
+
key: "current",
|
|
52
|
+
kind: "current",
|
|
53
|
+
heading: "Current",
|
|
54
|
+
models: [current],
|
|
55
|
+
});
|
|
56
|
+
taken.add(current.value);
|
|
57
|
+
}
|
|
58
|
+
const last = models.find((m) => m.value === lastValue);
|
|
59
|
+
if (last && !taken.has(last.value)) {
|
|
60
|
+
sections.push({
|
|
61
|
+
key: "last",
|
|
62
|
+
kind: "last",
|
|
63
|
+
heading: "Last used",
|
|
64
|
+
models: [last],
|
|
65
|
+
});
|
|
66
|
+
taken.add(last.value);
|
|
67
|
+
}
|
|
68
|
+
const def = models.find((m) => m.value === defaultValue);
|
|
69
|
+
if (def && !taken.has(def.value)) {
|
|
70
|
+
sections.push({
|
|
71
|
+
key: "default",
|
|
72
|
+
kind: "default",
|
|
73
|
+
heading: "Default",
|
|
74
|
+
models: [def],
|
|
75
|
+
});
|
|
76
|
+
taken.add(def.value);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const rest = models.filter(
|
|
80
|
+
(m) =>
|
|
81
|
+
!taken.has(m.value) && (!searching || matchesQuery(m, agentLabel, query))
|
|
82
|
+
);
|
|
83
|
+
const groups = new Map<string, ChatModel[]>();
|
|
84
|
+
for (const m of rest) {
|
|
85
|
+
const key = m.group ?? "";
|
|
86
|
+
groups.set(key, [...(groups.get(key) ?? []), m]);
|
|
87
|
+
}
|
|
88
|
+
for (const [key, list] of groups)
|
|
89
|
+
sections.push({
|
|
90
|
+
key: `group:${key}`,
|
|
91
|
+
kind: "group",
|
|
92
|
+
heading: key || "Models",
|
|
93
|
+
models: list,
|
|
94
|
+
});
|
|
95
|
+
return sections;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface Elsewhere {
|
|
99
|
+
/** Matches in the other agents. */
|
|
100
|
+
count: number;
|
|
101
|
+
/** The first of them that has any. */
|
|
102
|
+
agent: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** What a search that found nothing here would find in the other agents. */
|
|
106
|
+
export function matchesElsewhere(
|
|
107
|
+
agents: { agent: string; label: string; models: ChatModel[] }[],
|
|
108
|
+
current: string,
|
|
109
|
+
query: string
|
|
110
|
+
): Elsewhere | null {
|
|
111
|
+
if (!words(query).length) return null;
|
|
112
|
+
let count = 0;
|
|
113
|
+
let first: string | null = null;
|
|
114
|
+
for (const a of agents) {
|
|
115
|
+
if (a.agent === current) continue;
|
|
116
|
+
const n = a.models.filter((m) => matchesQuery(m, a.label, query)).length;
|
|
117
|
+
if (n && !first) first = a.agent;
|
|
118
|
+
count += n;
|
|
119
|
+
}
|
|
120
|
+
return first ? { count, agent: first } : null;
|
|
121
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { KeyboardEvent, RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
const ROWS = "[data-pick-row]:not(:disabled)";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Keyboard for a picker: ↑/↓ walk the rows (↑ from the first goes back to
|
|
7
|
+
* the search), ⏎ in the search takes the first row, / jumps to the search,
|
|
8
|
+
* and ←/→ step the agent when the caret isn't in the search.
|
|
9
|
+
*/
|
|
10
|
+
export function useListKeys(
|
|
11
|
+
root: RefObject<HTMLElement | null>,
|
|
12
|
+
search: RefObject<HTMLInputElement | null>,
|
|
13
|
+
onStepAgent?: (delta: 1 | -1) => void
|
|
14
|
+
) {
|
|
15
|
+
return (e: KeyboardEvent) => {
|
|
16
|
+
const el = root.current;
|
|
17
|
+
if (!el || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
18
|
+
const inSearch = e.target === search.current;
|
|
19
|
+
const rows = [...el.querySelectorAll<HTMLElement>(ROWS)];
|
|
20
|
+
const at = rows.indexOf(e.target as HTMLElement);
|
|
21
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
22
|
+
const next =
|
|
23
|
+
e.key === "ArrowDown"
|
|
24
|
+
? rows[inSearch || at < 0 ? 0 : Math.min(at + 1, rows.length - 1)]
|
|
25
|
+
: at > 0
|
|
26
|
+
? rows[at - 1]
|
|
27
|
+
: search.current;
|
|
28
|
+
if (!next) return;
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
next.focus();
|
|
31
|
+
} else if (e.key === "Enter" && inSearch) {
|
|
32
|
+
if (!rows[0]) return;
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
rows[0].click();
|
|
35
|
+
} else if (e.key === "/" && !inSearch) {
|
|
36
|
+
e.preventDefault();
|
|
37
|
+
search.current?.focus();
|
|
38
|
+
} else if (
|
|
39
|
+
(e.key === "ArrowLeft" || e.key === "ArrowRight") &&
|
|
40
|
+
!inSearch &&
|
|
41
|
+
onStepAgent
|
|
42
|
+
) {
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
onStepAgent(e.key === "ArrowRight" ? 1 : -1);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Focus the ticked model, else the first, so the arrows work at once. */
|
|
50
|
+
export function focusSelectedRow(root: HTMLElement | null): void {
|
|
51
|
+
const list = root?.querySelector("[data-model-list]") ?? root;
|
|
52
|
+
const rows = list?.querySelectorAll<HTMLElement>(ROWS);
|
|
53
|
+
(list?.querySelector<HTMLElement>("[data-selected]") ?? rows?.[0])?.focus();
|
|
54
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import type { ChatModel } from "@/lib/chat/events";
|
|
3
|
+
import {
|
|
4
|
+
buildSections,
|
|
5
|
+
matchesElsewhere,
|
|
6
|
+
type Elsewhere,
|
|
7
|
+
type Section,
|
|
8
|
+
} from "./sections";
|
|
9
|
+
|
|
10
|
+
interface AgentModels {
|
|
11
|
+
agent: string;
|
|
12
|
+
label: string;
|
|
13
|
+
models: ChatModel[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The query typed into a model list, and what it leaves: the sections to
|
|
18
|
+
* show and, when this agent has no hit, the matches in the others.
|
|
19
|
+
*/
|
|
20
|
+
export function useModelSearch({
|
|
21
|
+
agent,
|
|
22
|
+
agents,
|
|
23
|
+
defaultValue,
|
|
24
|
+
lastValue,
|
|
25
|
+
currentValue,
|
|
26
|
+
open,
|
|
27
|
+
}: {
|
|
28
|
+
agent: AgentModels;
|
|
29
|
+
/** Every agent that can be picked, to look for matches beyond this one. */
|
|
30
|
+
agents?: AgentModels[];
|
|
31
|
+
defaultValue?: string;
|
|
32
|
+
lastValue?: string;
|
|
33
|
+
currentValue?: string;
|
|
34
|
+
/** A picker that opens starts with an empty search. */
|
|
35
|
+
open: boolean;
|
|
36
|
+
}): {
|
|
37
|
+
query: string;
|
|
38
|
+
setQuery: (q: string) => void;
|
|
39
|
+
sections: Section[];
|
|
40
|
+
elsewhere: Elsewhere | null;
|
|
41
|
+
} {
|
|
42
|
+
const [query, setQuery] = useState("");
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (open) setQuery("");
|
|
45
|
+
}, [open]);
|
|
46
|
+
const sections = useMemo(
|
|
47
|
+
() =>
|
|
48
|
+
buildSections({
|
|
49
|
+
models: agent.models,
|
|
50
|
+
agentLabel: agent.label,
|
|
51
|
+
defaultValue,
|
|
52
|
+
lastValue,
|
|
53
|
+
currentValue,
|
|
54
|
+
query,
|
|
55
|
+
}),
|
|
56
|
+
[agent.models, agent.label, defaultValue, lastValue, currentValue, query]
|
|
57
|
+
);
|
|
58
|
+
const elsewhere = useMemo(
|
|
59
|
+
() =>
|
|
60
|
+
sections.length === 0 && agents
|
|
61
|
+
? matchesElsewhere(agents, agent.agent, query)
|
|
62
|
+
: null,
|
|
63
|
+
[sections.length, agents, agent.agent, query]
|
|
64
|
+
);
|
|
65
|
+
return { query, setQuery, sections, elsewhere };
|
|
66
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { WhatsNewButton } from "@/components/WhatsNew";
|
|
1
2
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
|
2
3
|
import {
|
|
3
4
|
Tooltip,
|
|
@@ -8,7 +9,8 @@ import {
|
|
|
8
9
|
export function SidebarFooter() {
|
|
9
10
|
return (
|
|
10
11
|
<div className="mt-auto px-3 pt-2 pb-3">
|
|
11
|
-
<
|
|
12
|
+
<WhatsNewButton />
|
|
13
|
+
<div className="mt-1 flex items-center justify-between">
|
|
12
14
|
<span className="label-mono text-muted-foreground">Theme</span>
|
|
13
15
|
<ThemeToggle />
|
|
14
16
|
</div>
|
|
@@ -89,7 +89,7 @@ describe("UpdateBanner", () => {
|
|
|
89
89
|
expect(
|
|
90
90
|
document.querySelector<HTMLAnchorElement>("a")?.getAttribute("href")
|
|
91
91
|
).toBe("https://example.com/compare");
|
|
92
|
-
await act(async () => button("
|
|
92
|
+
await act(async () => button("Commit list")!.click());
|
|
93
93
|
expect(text()).toContain("Faster boot (#9)");
|
|
94
94
|
});
|
|
95
95
|
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
type UpdateView,
|
|
13
13
|
} from "@/lib/updates/banner";
|
|
14
14
|
import { cn } from "@/lib/utils";
|
|
15
|
+
import { WhatsNewHost } from "@/components/WhatsNew";
|
|
16
|
+
import { whatsNewActions } from "@/stores/whatsNew";
|
|
15
17
|
import { UpdateActions } from "./UpdateActions";
|
|
16
18
|
|
|
17
19
|
const STEPS = ["pulling", "installing", "building", "restarting"] as const;
|
|
@@ -138,7 +140,7 @@ export function UpdateBannerView({
|
|
|
138
140
|
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center gap-1 text-xs md:min-h-0"
|
|
139
141
|
onClick={() => setNotesOpen(!notesOpen)}
|
|
140
142
|
>
|
|
141
|
-
|
|
143
|
+
Commit list
|
|
142
144
|
<ChevronDown
|
|
143
145
|
className={cn(
|
|
144
146
|
"h-3 w-3 transition-transform",
|
|
@@ -147,6 +149,15 @@ export function UpdateBannerView({
|
|
|
147
149
|
/>
|
|
148
150
|
</button>
|
|
149
151
|
)}
|
|
152
|
+
{state === "available" && (
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
className="text-primary inline-flex min-h-11 items-center text-xs underline md:min-h-0"
|
|
156
|
+
onClick={() => whatsNewActions.open(true)}
|
|
157
|
+
>
|
|
158
|
+
See what's new
|
|
159
|
+
</button>
|
|
160
|
+
)}
|
|
150
161
|
{state === "available" && latest?.url && (
|
|
151
162
|
<a
|
|
152
163
|
href={latest.url}
|
|
@@ -195,18 +206,21 @@ export function UpdateBanner() {
|
|
|
195
206
|
dismissedVersion: dismissed.version,
|
|
196
207
|
dismissedRun: dismissed.run,
|
|
197
208
|
});
|
|
198
|
-
if (state === "hidden" || !view) return
|
|
209
|
+
if (state === "hidden" || !view) return <WhatsNewHost />;
|
|
199
210
|
return (
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
? dismissed.
|
|
208
|
-
:
|
|
209
|
-
|
|
210
|
-
|
|
211
|
+
<>
|
|
212
|
+
<WhatsNewHost />
|
|
213
|
+
<UpdateBannerView
|
|
214
|
+
state={state}
|
|
215
|
+
view={view}
|
|
216
|
+
onDismiss={() =>
|
|
217
|
+
state === "available" && view.latest
|
|
218
|
+
? dismissed.dismissVersion(view.latest.version)
|
|
219
|
+
: view.run
|
|
220
|
+
? dismissed.dismissRun(view.run.id)
|
|
221
|
+
: undefined
|
|
222
|
+
}
|
|
223
|
+
/>
|
|
224
|
+
</>
|
|
211
225
|
);
|
|
212
226
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Sparkles } from "lucide-react";
|
|
4
|
+
import { StatusPill } from "@/components/ui/status-pill";
|
|
5
|
+
import { useWhatsNewHead } from "@/data/whats-new";
|
|
6
|
+
import { useWhatsNewSeen } from "@/hooks/useWhatsNewSeen";
|
|
7
|
+
import { unreadLabel } from "@/lib/whats-new/unread";
|
|
8
|
+
import { whatsNewActions } from "@/stores/whatsNew";
|
|
9
|
+
|
|
10
|
+
/** The sidebar footer row; it opens the one dialog (WhatsNewHost). */
|
|
11
|
+
export function WhatsNewButton() {
|
|
12
|
+
const { data: head } = useWhatsNewHead();
|
|
13
|
+
const { unread } = useWhatsNewSeen(head);
|
|
14
|
+
return (
|
|
15
|
+
<button
|
|
16
|
+
type="button"
|
|
17
|
+
onClick={() => whatsNewActions.open()}
|
|
18
|
+
className="text-foreground hover:bg-accent/60 flex min-h-11 w-full items-center gap-2.5 rounded-lg px-2.5 text-[13px] transition-colors md:min-h-9"
|
|
19
|
+
>
|
|
20
|
+
<Sparkles className="text-muted-foreground size-4 shrink-0" />
|
|
21
|
+
<span className="flex-1 text-left">What's New</span>
|
|
22
|
+
{unread > 0 && (
|
|
23
|
+
<>
|
|
24
|
+
<StatusPill tone="primary" className="rounded-full px-2">
|
|
25
|
+
{unreadLabel(unread)} new
|
|
26
|
+
</StatusPill>
|
|
27
|
+
<span
|
|
28
|
+
role="img"
|
|
29
|
+
aria-label="Unread changes"
|
|
30
|
+
className="bg-primary size-2 shrink-0 rounded-full"
|
|
31
|
+
/>
|
|
32
|
+
</>
|
|
33
|
+
)}
|
|
34
|
+
</button>
|
|
35
|
+
);
|
|
36
|
+
}
|