@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.
Files changed (71) 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 +3 -1
  27. package/components/Updates/UpdateBanner.test.ts +1 -1
  28. package/components/Updates/UpdateBanner.tsx +27 -13
  29. package/components/WhatsNew/WhatsNewButton.tsx +36 -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 +1773 -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 +69 -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 +14 -0
  65. package/package.json +2 -1
  66. package/scripts/autodeploy +28 -0
  67. package/scripts/autodeploy.test.ts +53 -0
  68. package/scripts/whats-new.ts +118 -0
  69. package/stores/whatsNew.ts +16 -0
  70. package/components/Draft/draftModels.test.ts +0 -31
  71. 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
- <div className="flex items-center justify-between">
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("What's new")!.click());
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
- What&apos;s new
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&apos;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 null;
209
+ if (state === "hidden" || !view) return <WhatsNewHost />;
199
210
  return (
200
- <UpdateBannerView
201
- state={state}
202
- view={view}
203
- onDismiss={() =>
204
- state === "available" && view.latest
205
- ? dismissed.dismissVersion(view.latest.version)
206
- : view.run
207
- ? dismissed.dismissRun(view.run.id)
208
- : undefined
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&apos;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
+ }