@saadnvd1/agent-os 0.3.524 → 0.3.527

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 (54) hide show
  1. package/README.md +25 -3
  2. package/app/api/doctor/route.ts +8 -0
  3. package/app/api/workspaces/[id]/orchestrator/needs-you/dismiss/route.ts +31 -0
  4. package/app/api/workspaces/[id]/orchestrator/questions/[questionId]/route.ts +15 -0
  5. package/app/page.tsx +8 -3
  6. package/components/Chat/ChatPanel.tsx +96 -8
  7. package/components/Chat/EventRow.tsx +2 -2
  8. package/components/Chat/EventTurns.test.ts +56 -0
  9. package/components/Chat/EventTurns.tsx +141 -0
  10. package/components/Chat/useFind.ts +5 -3
  11. package/components/Chat/useGoToItem.ts +71 -0
  12. package/components/Orchestrator/AsksList.tsx +16 -11
  13. package/components/Orchestrator/NeedsYouStrip.tsx +148 -0
  14. package/components/Orchestrator/OrchestratorBar.tsx +27 -2
  15. package/components/Settings/SettingsDialog.tsx +2 -2
  16. package/components/Settings/SetupSection.tsx +6 -1
  17. package/components/Simulator/ScreenshotStrip.test.ts +91 -0
  18. package/components/Simulator/ScreenshotStrip.tsx +102 -0
  19. package/components/Simulator/index.ts +1 -0
  20. package/components/Tasks/TaskRow.tsx +2 -0
  21. package/components/Welcome/DevicesStep.tsx +90 -0
  22. package/components/Welcome/HealthList.tsx +69 -0
  23. package/components/Welcome/ProjectStep.tsx +2 -8
  24. package/components/Welcome/WelcomeDialog.tsx +31 -7
  25. package/data/doctor/index.ts +29 -0
  26. package/data/orchestrators/index.ts +59 -0
  27. package/data/push/topics.ts +2 -0
  28. package/data/simulator/index.test.ts +14 -0
  29. package/data/simulator/index.ts +33 -0
  30. package/data/simulator/keys.ts +5 -0
  31. package/hooks/useNeedsYou.ts +53 -0
  32. package/lib/chat/events.ts +2 -0
  33. package/lib/chat/find.ts +25 -4
  34. package/lib/chat/group.ts +50 -2
  35. package/lib/chat/turns.ts +111 -0
  36. package/lib/db/changes.ts +6 -0
  37. package/lib/db/migrations.ts +32 -0
  38. package/lib/doctor/checks.ts +222 -0
  39. package/lib/orchestrator/ask-view.ts +13 -0
  40. package/lib/orchestrator/brief.ts +5 -1
  41. package/lib/orchestrator/deliver.ts +3 -0
  42. package/lib/orchestrator/events.ts +19 -0
  43. package/lib/orchestrator/needs-you-items.ts +104 -0
  44. package/lib/orchestrator/overview.ts +3 -0
  45. package/lib/orchestrator/questions.ts +120 -0
  46. package/lib/orchestrator/serve.ts +11 -0
  47. package/lib/orchestrator/tool-names.ts +2 -0
  48. package/lib/orchestrator/tool-schemas.ts +12 -0
  49. package/lib/orchestrator/tools.ts +4 -0
  50. package/lib/welcome-url.ts +6 -1
  51. package/package.json +1 -1
  52. package/stores/needsYouFocus.ts +13 -0
  53. package/stores/welcomeUi.test.ts +15 -0
  54. package/stores/welcomeUi.ts +6 -1
@@ -0,0 +1,148 @@
1
+ "use client";
2
+
3
+ import { memo, useState } from "react";
4
+ import {
5
+ AlertCircle,
6
+ ChevronDown,
7
+ CornerDownLeft,
8
+ GitMerge,
9
+ MessageCircleQuestion,
10
+ UserRound,
11
+ X,
12
+ } from "lucide-react";
13
+ import { toast } from "sonner";
14
+ import { useDismissNeedsYou } from "@/data/orchestrators";
15
+ import { useNeedsYou } from "@/hooks/useNeedsYou";
16
+ import type { NeedsYouItem } from "@/lib/orchestrator/needs-you-items";
17
+ import { cn } from "@/lib/utils";
18
+
19
+ const ICON = {
20
+ ask: AlertCircle,
21
+ question: MessageCircleQuestion,
22
+ chat: MessageCircleQuestion,
23
+ task: UserRound,
24
+ } as const;
25
+
26
+ const STORE = "agentos:needs-you-open";
27
+
28
+ export interface NeedsYouActions {
29
+ // Show the item where it is: the ask's card, the message, the session.
30
+ onGo: (item: NeedsYouItem) => void;
31
+ // Answer a question in the composer.
32
+ onReply: (item: Extract<NeedsYouItem, { kind: "question" }>) => void;
33
+ }
34
+
35
+ // Pinned above the composer: everything waiting on Saad, one line each, until
36
+ // it is resolved. Dismissing an item only hides it; an ask stays open.
37
+ export const NeedsYouStrip = memo(function NeedsYouStrip({
38
+ workspaceId,
39
+ chatWaiting,
40
+ onGo,
41
+ onReply,
42
+ }: NeedsYouActions & { workspaceId: string; chatWaiting: boolean }) {
43
+ const items = useNeedsYou(workspaceId, chatWaiting);
44
+ const dismiss = useDismissNeedsYou(workspaceId);
45
+ // Open on a wide screen, closed on a phone, then as he left it. It renders
46
+ // nothing until the orchestrator's data has loaded, so never on the server.
47
+ const [open, setOpen] = useState(() => {
48
+ if (typeof window === "undefined") return false;
49
+ const saved = localStorage.getItem(STORE);
50
+ return saved ? saved === "1" : window.innerWidth >= 768;
51
+ });
52
+ if (!items.length) return null;
53
+ const toggle = () => {
54
+ localStorage.setItem(STORE, open ? "0" : "1");
55
+ setOpen(!open);
56
+ };
57
+ const hide = (item: NeedsYouItem) =>
58
+ dismiss.mutate(
59
+ { key: item.key },
60
+ {
61
+ onSuccess: () =>
62
+ toast("Hidden from Needs you", {
63
+ action: {
64
+ label: "Undo",
65
+ onClick: () => dismiss.mutate({ key: item.key, undo: true }),
66
+ },
67
+ }),
68
+ onError: (e) => toast.error(e.message),
69
+ }
70
+ );
71
+ const newest = items[0];
72
+
73
+ return (
74
+ <section
75
+ aria-label="Needs you"
76
+ data-needs-you
77
+ className="mb-2 overflow-hidden rounded-xl border border-amber-500/30 bg-amber-500/[0.06]"
78
+ >
79
+ <button
80
+ type="button"
81
+ onClick={toggle}
82
+ aria-expanded={open}
83
+ className="flex min-h-11 w-full items-center gap-2 px-3 text-left text-xs"
84
+ >
85
+ <span className="rounded-full bg-amber-500/20 px-1.5 py-0.5 font-mono text-[10px] font-medium text-amber-700 dark:text-amber-300">
86
+ {items.length}
87
+ </span>
88
+ <span className="font-medium text-amber-700 dark:text-amber-300">
89
+ Needs you
90
+ </span>
91
+ {!open && (
92
+ <span className="text-muted-foreground min-w-0 flex-1 truncate">
93
+ {newest.title}
94
+ </span>
95
+ )}
96
+ <ChevronDown
97
+ className={cn(
98
+ "text-muted-foreground ml-auto h-3.5 w-3.5 shrink-0 transition-transform",
99
+ !open && "-rotate-90"
100
+ )}
101
+ />
102
+ </button>
103
+ {open && (
104
+ <ul className="max-h-48 divide-y divide-amber-500/15 overflow-y-auto border-t border-amber-500/15">
105
+ {items.map((item) => {
106
+ const Icon =
107
+ item.kind === "ask" && item.ask.kind === "gate"
108
+ ? GitMerge
109
+ : ICON[item.kind];
110
+ return (
111
+ <li key={item.key} className="flex items-center">
112
+ <button
113
+ type="button"
114
+ onClick={() => onGo(item)}
115
+ className="hover:bg-foreground/[0.04] flex min-h-11 min-w-0 flex-1 items-center gap-2 px-3 text-left text-xs"
116
+ >
117
+ <Icon className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
118
+ <span className="min-w-0 flex-1 truncate">{item.title}</span>
119
+ <span className="text-muted-foreground/70 hidden max-w-[30%] shrink-0 truncate sm:block">
120
+ {item.sub}
121
+ </span>
122
+ </button>
123
+ {item.kind === "question" && (
124
+ <button
125
+ type="button"
126
+ onClick={() => onReply(item)}
127
+ aria-label="Reply"
128
+ className="text-muted-foreground hover:text-foreground flex size-11 shrink-0 items-center justify-center"
129
+ >
130
+ <CornerDownLeft className="h-3.5 w-3.5" />
131
+ </button>
132
+ )}
133
+ <button
134
+ type="button"
135
+ onClick={() => hide(item)}
136
+ aria-label="Dismiss"
137
+ className="text-muted-foreground hover:text-foreground flex size-11 shrink-0 items-center justify-center"
138
+ >
139
+ <X className="h-3.5 w-3.5" />
140
+ </button>
141
+ </li>
142
+ );
143
+ })}
144
+ </ul>
145
+ )}
146
+ </section>
147
+ );
148
+ });
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { memo, useState, useSyncExternalStore } from "react";
3
+ import { memo, useEffect, useState, useSyncExternalStore } from "react";
4
+ import { useSnapshot } from "valtio";
4
5
  import { ChevronDown, Pause, Play } from "lucide-react";
5
6
  import { toast } from "sonner";
6
7
  import { Button } from "@/components/ui/button";
@@ -10,6 +11,7 @@ import { headerParts } from "@/lib/orchestrator/header-line";
10
11
  import { cn } from "@/lib/utils";
11
12
  import { useSchedulesGlance } from "@/components/SessionList/useSchedulesGlance";
12
13
  import { schedulesUiActions } from "@/stores/schedulesUi";
14
+ import { needsYouFocus } from "@/stores/needsYouFocus";
13
15
  import { NO_PASSKEYS_HERE, passkeysHere } from "@/data/presence";
14
16
  import { AsksList } from "./AsksList";
15
17
 
@@ -24,7 +26,30 @@ export const OrchestratorBar = memo(function OrchestratorBar({
24
26
  const { overview, counts } = useOrchestratorHeader(workspaceId);
25
27
  const pause = usePauseOrchestrator(workspaceId);
26
28
  const { upcomingText } = useSchedulesGlance(workspaceId);
27
- const [open, setOpen] = useState(true);
29
+ // Closed: the Needs-you strip above the composer lists them.
30
+ const [open, setOpen] = useState(false);
31
+ const { ask: focus } = useSnapshot(needsYouFocus);
32
+ useEffect(() => {
33
+ if (!focus) return;
34
+ // Open the list, then find the card once it is there.
35
+ let second = 0;
36
+ const first = requestAnimationFrame(() => {
37
+ setOpen(true);
38
+ second = requestAnimationFrame(() => {
39
+ const card = document.getElementById(`ask-${focus.id}`);
40
+ card?.scrollIntoView({ block: "center", behavior: "smooth" });
41
+ card?.classList.add("ring-2", "ring-amber-500/60");
42
+ setTimeout(
43
+ () => card?.classList.remove("ring-2", "ring-amber-500/60"),
44
+ 1600
45
+ );
46
+ });
47
+ });
48
+ return () => {
49
+ cancelAnimationFrame(first);
50
+ cancelAnimationFrame(second);
51
+ };
52
+ }, [focus]);
28
53
  const canProve = useSyncExternalStore(
29
54
  () => () => {},
30
55
  passkeysHere,
@@ -81,8 +81,8 @@ export const SECTION_INFO: Record<
81
81
  icon: ClipboardList,
82
82
  },
83
83
  setup: {
84
- label: "Setup",
85
- hint: "Agents on this machine, first-run setup",
84
+ label: "Setup & health",
85
+ hint: "What this machine has, agents, first-run setup",
86
86
  icon: ListChecks,
87
87
  },
88
88
  };
@@ -1,11 +1,12 @@
1
1
  "use client";
2
2
 
3
3
  import { Button } from "@/components/ui/button";
4
+ import { HealthList } from "@/components/Welcome/HealthList";
4
5
  import { AgentList } from "@/components/Welcome/AgentList";
5
6
  import { settingsUiActions } from "@/stores/settingsUi";
6
7
  import { welcomeUiActions } from "@/stores/welcomeUi";
7
8
 
8
- // What this machine has for running agents, and the first-run setup again.
9
+ // What this machine has for running AgentOS and agents, and the first-run setup again.
9
10
  export function SetupSection() {
10
11
  return (
11
12
  <div className="flex flex-col gap-4">
@@ -28,6 +29,10 @@ export function SetupSection() {
28
29
  Run setup again
29
30
  </Button>
30
31
  </div>
32
+ <div className="flex flex-col gap-2">
33
+ <h3 className="text-sm font-medium">Health</h3>
34
+ <HealthList />
35
+ </div>
31
36
  <div className="flex flex-col gap-2">
32
37
  <h3 className="text-sm font-medium">Agents on this machine</h3>
33
38
  <AgentList />
@@ -0,0 +1,91 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type { SimulatorStatus } from "@/data/simulator";
6
+
7
+ for (const key of [
8
+ "Event",
9
+ "CustomEvent",
10
+ "FocusEvent",
11
+ "PopStateEvent",
12
+ "NodeFilter",
13
+ "HTMLButtonElement",
14
+ ])
15
+ Object.defineProperty(globalThis, key, {
16
+ value: (window as unknown as Record<string, unknown>)[key],
17
+ configurable: true,
18
+ writable: true,
19
+ });
20
+
21
+ let status: Partial<SimulatorStatus> | undefined;
22
+ vi.mock("@/data/simulator", () => ({
23
+ useSimulatorStatus: () => ({ data: status }),
24
+ }));
25
+
26
+ import { ScreenshotStrip, SimulatorIndicator } from "./ScreenshotStrip";
27
+
28
+ (
29
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
30
+ ).IS_REACT_ACT_ENVIRONMENT = true;
31
+
32
+ let root: Root | null = null;
33
+ let host: HTMLElement | null = null;
34
+ async function mount(el: ReturnType<typeof createElement>) {
35
+ host = document.createElement("div");
36
+ document.body.appendChild(host);
37
+ root = createRoot(host);
38
+ await act(async () => root!.render(el));
39
+ }
40
+ afterEach(async () => {
41
+ await act(async () => root?.unmount());
42
+ host?.remove();
43
+ document.body.innerHTML = "";
44
+ });
45
+
46
+ const indicator = () => createElement(SimulatorIndicator, { sessionId: "s1" });
47
+ const button = () => document.querySelector("button[aria-label^='Simulator']");
48
+
49
+ describe("SimulatorIndicator", () => {
50
+ it("renders nothing without a lease, while starting, or once shut down", async () => {
51
+ for (const state of [undefined, "none", "starting", "shutdown", "failed"]) {
52
+ status = state ? ({ state, shots: [] } as never) : undefined;
53
+ await mount(indicator());
54
+ expect(button()).toBeNull();
55
+ await act(async () => root!.unmount());
56
+ host!.remove();
57
+ }
58
+ });
59
+
60
+ it("shows the phone with no count when booted and no shots yet", async () => {
61
+ status = { state: "ready", shots: [] };
62
+ await mount(indicator());
63
+ expect(button()?.getAttribute("aria-label")).toBe(
64
+ "Simulator booted, 0 screenshots"
65
+ );
66
+ expect(button()?.textContent).toBe("");
67
+ });
68
+
69
+ it("shows the screenshot count and opens the strip, then a shot full size", async () => {
70
+ status = { state: "ready", shots: ["/t/a b.png", "/t/c.png"] };
71
+ await mount(indicator());
72
+ expect(button()?.textContent).toBe("2");
73
+ await act(async () => (button() as HTMLElement).click());
74
+ const thumbs = document.querySelectorAll("img");
75
+ expect(thumbs).toHaveLength(2);
76
+ expect(thumbs[0].getAttribute("src")).toBe(
77
+ "/api/files/image?path=%2Ft%2Fa%20b.png"
78
+ );
79
+ await act(async () => (thumbs[1].closest("button") as HTMLElement).click());
80
+ const full = document.querySelectorAll("img");
81
+ expect(full).toHaveLength(1);
82
+ expect(full[0].getAttribute("src")).toContain("c.png");
83
+ });
84
+ });
85
+
86
+ describe("ScreenshotStrip", () => {
87
+ it("says so when there are none", async () => {
88
+ await mount(createElement(ScreenshotStrip, { shots: [] }));
89
+ expect(document.body.textContent).toContain("No screenshots yet");
90
+ });
91
+ });
@@ -0,0 +1,102 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { ArrowLeft, Smartphone } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogDescription,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ DialogTrigger,
13
+ } from "@/components/ui/dialog";
14
+ import { useSimulatorStatus } from "@/data/simulator";
15
+ import { cn } from "@/lib/utils";
16
+
17
+ export const shotUrl = (file: string) =>
18
+ `/api/files/image?path=${encodeURIComponent(file)}`;
19
+
20
+ // The phone icon on a task whose simulator is up; tapping it opens the
21
+ // latest screenshots.
22
+ export function SimulatorIndicator({ sessionId }: { sessionId: string }) {
23
+ const { data } = useSimulatorStatus(sessionId);
24
+ const [open, setOpen] = useState(false);
25
+ if (data?.state !== "ready") return null;
26
+ return (
27
+ <Dialog open={open} onOpenChange={setOpen}>
28
+ <DialogTrigger asChild>
29
+ <Button
30
+ size="sm"
31
+ variant="outline"
32
+ className="h-11 md:h-8"
33
+ aria-label={`Simulator booted, ${data.shots.length} screenshots`}
34
+ >
35
+ <Smartphone className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
36
+ {data.shots.length > 0 && (
37
+ <span className="font-mono text-[11px]">{data.shots.length}</span>
38
+ )}
39
+ </Button>
40
+ </DialogTrigger>
41
+ <DialogContent className="max-h-[90dvh] overflow-y-auto">
42
+ <DialogHeader>
43
+ <DialogTitle>Simulator</DialogTitle>
44
+ <DialogDescription>
45
+ Latest screenshots, newest last.
46
+ </DialogDescription>
47
+ </DialogHeader>
48
+ <ScreenshotStrip shots={data.shots} />
49
+ </DialogContent>
50
+ </Dialog>
51
+ );
52
+ }
53
+
54
+ export function ScreenshotStrip({ shots }: { shots: string[] }) {
55
+ const [selected, setSelected] = useState<string | null>(null);
56
+ if (shots.length === 0)
57
+ return <p className="text-muted-foreground text-sm">No screenshots yet.</p>;
58
+ if (selected)
59
+ return (
60
+ <div className="space-y-3">
61
+ <Button
62
+ size="sm"
63
+ variant="ghost"
64
+ className="h-11 md:h-8"
65
+ onClick={() => setSelected(null)}
66
+ >
67
+ <ArrowLeft className="h-3.5 w-3.5" />
68
+ All screenshots
69
+ </Button>
70
+ {/* eslint-disable-next-line @next/next/no-img-element */}
71
+ <img
72
+ src={shotUrl(selected)}
73
+ alt="Simulator screenshot"
74
+ className="mx-auto max-h-[70dvh] w-auto rounded-lg border"
75
+ />
76
+ </div>
77
+ );
78
+ return (
79
+ <ul className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-2">
80
+ {shots.map((file, i) => (
81
+ <li key={file} className="shrink-0">
82
+ <button
83
+ type="button"
84
+ onClick={() => setSelected(file)}
85
+ aria-label={`Screenshot ${i + 1} of ${shots.length}`}
86
+ className={cn(
87
+ "bg-foreground/[0.04] focus-visible:ring-ring block min-h-11 min-w-11 overflow-hidden rounded-lg border focus-visible:ring-2 focus-visible:outline-none"
88
+ )}
89
+ >
90
+ {/* eslint-disable-next-line @next/next/no-img-element */}
91
+ <img
92
+ src={shotUrl(file)}
93
+ alt=""
94
+ loading="lazy"
95
+ className="h-40 w-auto"
96
+ />
97
+ </button>
98
+ </li>
99
+ ))}
100
+ </ul>
101
+ );
102
+ }
@@ -0,0 +1 @@
1
+ export { SimulatorIndicator, ScreenshotStrip } from "./ScreenshotStrip";
@@ -20,6 +20,7 @@ import { Markdown } from "@/components/Chat/Markdown";
20
20
  import { tmuxAttachActions } from "@/stores/tmuxAttach";
21
21
  import { tasksUiActions } from "@/stores/tasksUi";
22
22
  import { cn } from "@/lib/utils";
23
+ import { SimulatorIndicator } from "@/components/Simulator";
23
24
  import { MoveTaskButton } from "./MoveTaskButton";
24
25
  import { ResumeOnModel } from "./ResumeOnModel";
25
26
  import { TASK_STATE as STATE } from "./task-state-labels";
@@ -118,6 +119,7 @@ export function TaskRow({ task }: { task: TaskView }) {
118
119
  </a>
119
120
  </Button>
120
121
  )}
122
+ {!task.hostId && <SimulatorIndicator sessionId={task.id} />}
121
123
  <MoveTaskButton task={task} onError={setMoveError} />
122
124
  {!task.hostId && <ResumeOnModel task={task} />}
123
125
  <span className="flex-1" />
@@ -0,0 +1,90 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Bell, Laptop, Smartphone } from "lucide-react";
5
+ import { AddDevicePanel } from "@/components/Devices/AddDevicePanel";
6
+ import {
7
+ NotificationsSection,
8
+ type BrowserNotifications,
9
+ } from "@/components/Settings/NotificationsSection";
10
+ import { Button } from "@/components/ui/button";
11
+
12
+ function Block({
13
+ icon: Icon,
14
+ title,
15
+ detail,
16
+ children,
17
+ }: {
18
+ icon: typeof Bell;
19
+ title: string;
20
+ detail: string;
21
+ children: React.ReactNode;
22
+ }) {
23
+ return (
24
+ <section className="bg-muted/40 flex min-w-0 flex-col gap-3 rounded-lg px-3 py-3">
25
+ <div className="flex items-start gap-3">
26
+ <Icon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
27
+ <div className="min-w-0 flex-1">
28
+ <h3 className="text-sm font-medium">{title}</h3>
29
+ <p className="text-muted-foreground text-xs">{detail}</p>
30
+ </div>
31
+ </div>
32
+ {children}
33
+ </section>
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Pair a phone, link another machine, set up notifications. Every part is
39
+ * optional and lives in Settings too.
40
+ */
41
+ export function DevicesStep({
42
+ notifications,
43
+ onLinkMachine,
44
+ }: {
45
+ notifications: BrowserNotifications;
46
+ onLinkMachine: () => void;
47
+ }) {
48
+ const [pairing, setPairing] = useState(false);
49
+ return (
50
+ <div className="flex min-w-0 flex-col gap-3">
51
+ <Block
52
+ icon={Smartphone}
53
+ title="Pair your phone"
54
+ detail="Scan a code to use AgentOS from your phone."
55
+ >
56
+ {pairing ? (
57
+ <AddDevicePanel onDone={() => setPairing(false)} />
58
+ ) : (
59
+ <Button
60
+ variant="outline"
61
+ className="min-h-11 sm:min-h-9"
62
+ onClick={() => setPairing(true)}
63
+ >
64
+ Show pairing code
65
+ </Button>
66
+ )}
67
+ </Block>
68
+ <Block
69
+ icon={Laptop}
70
+ title="Link another machine"
71
+ detail="Run sessions on a server or second computer over ssh."
72
+ >
73
+ <Button
74
+ variant="outline"
75
+ className="min-h-11 sm:min-h-9"
76
+ onClick={onLinkMachine}
77
+ >
78
+ Add a machine
79
+ </Button>
80
+ </Block>
81
+ <Block
82
+ icon={Bell}
83
+ title="Notifications"
84
+ detail="Hear when a session needs you."
85
+ >
86
+ <NotificationsSection browser={notifications} />
87
+ </Block>
88
+ </div>
89
+ );
90
+ }
@@ -0,0 +1,69 @@
1
+ "use client";
2
+
3
+ import { AlertTriangle, Check, RefreshCw, X } from "lucide-react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { useDoctorQuery, useRecheckDoctor } from "@/data/doctor";
6
+ import type { CheckStatus } from "@/lib/doctor/checks";
7
+ import { cn } from "@/lib/utils";
8
+
9
+ const ICON = { ok: Check, warn: AlertTriangle, fail: X } as const;
10
+ const TONE: Record<CheckStatus, string> = {
11
+ ok: "text-primary",
12
+ warn: "text-muted-foreground",
13
+ fail: "text-destructive",
14
+ };
15
+
16
+ /** What this machine needs to run AgentOS, one line per tool. */
17
+ export function HealthList() {
18
+ const { data: checks, isPending, isError } = useDoctorQuery();
19
+ const recheck = useRecheckDoctor();
20
+ return (
21
+ <div className="flex flex-col gap-2">
22
+ {isPending && (
23
+ <div className="bg-muted/40 h-24 animate-pulse rounded-lg" />
24
+ )}
25
+ {isError && (
26
+ <p className="text-destructive text-sm">
27
+ Couldn&apos;t run the health checks.
28
+ </p>
29
+ )}
30
+ <ul className="flex flex-col gap-2">
31
+ {checks?.map((c) => {
32
+ const Icon = ICON[c.status];
33
+ return (
34
+ <li
35
+ key={c.id}
36
+ className="bg-muted/40 flex min-h-11 items-start gap-3 rounded-lg px-3 py-2"
37
+ >
38
+ <Icon
39
+ aria-label={c.status}
40
+ className={cn("mt-0.5 size-4 shrink-0", TONE[c.status])}
41
+ />
42
+ <div className="min-w-0 flex-1">
43
+ <p className="text-sm font-medium">{c.label}</p>
44
+ <p className="text-muted-foreground text-xs break-words">
45
+ {c.detail}
46
+ </p>
47
+ {c.fix && c.status !== "ok" && (
48
+ <code className="bg-background mt-1 block rounded px-2 py-1 text-xs break-all">
49
+ {c.fix}
50
+ </code>
51
+ )}
52
+ </div>
53
+ </li>
54
+ );
55
+ })}
56
+ </ul>
57
+ <Button
58
+ size="sm"
59
+ variant="ghost"
60
+ className="self-end"
61
+ disabled={recheck.isPending}
62
+ onClick={() => recheck.mutate()}
63
+ >
64
+ <RefreshCw className={recheck.isPending ? "animate-spin" : ""} />
65
+ Check again
66
+ </Button>
67
+ </div>
68
+ );
69
+ }
@@ -41,10 +41,7 @@ export function ProjectStep() {
41
41
  const own = projects.filter((p) => !p.is_uncategorized);
42
42
 
43
43
  const add = (kind: AddProjectKind) =>
44
- addProjectActions.show(kind, "local", (id) => {
45
- welcomeUiActions.setProject(id);
46
- welcomeUiActions.go("finish");
47
- });
44
+ addProjectActions.show(kind, "local", welcomeUiActions.chooseProject);
48
45
 
49
46
  return (
50
47
  <div className="flex flex-col gap-3">
@@ -54,10 +51,7 @@ export function ProjectStep() {
54
51
  <li key={p.id}>
55
52
  <button
56
53
  type="button"
57
- onClick={() => {
58
- welcomeUiActions.setProject(p.id);
59
- welcomeUiActions.go("finish");
60
- }}
54
+ onClick={() => welcomeUiActions.chooseProject(p.id)}
61
55
  className={cn(
62
56
  "bg-muted/40 hover:bg-accent flex min-h-11 w-full items-center gap-3 rounded-lg px-3 py-2 text-left",
63
57
  projectId === p.id && "ring-primary/40 ring-2"