@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.
- package/README.md +25 -3
- package/app/api/doctor/route.ts +8 -0
- package/app/api/workspaces/[id]/orchestrator/needs-you/dismiss/route.ts +31 -0
- package/app/api/workspaces/[id]/orchestrator/questions/[questionId]/route.ts +15 -0
- package/app/page.tsx +8 -3
- package/components/Chat/ChatPanel.tsx +96 -8
- package/components/Chat/EventRow.tsx +2 -2
- package/components/Chat/EventTurns.test.ts +56 -0
- package/components/Chat/EventTurns.tsx +141 -0
- package/components/Chat/useFind.ts +5 -3
- package/components/Chat/useGoToItem.ts +71 -0
- package/components/Orchestrator/AsksList.tsx +16 -11
- package/components/Orchestrator/NeedsYouStrip.tsx +148 -0
- package/components/Orchestrator/OrchestratorBar.tsx +27 -2
- package/components/Settings/SettingsDialog.tsx +2 -2
- package/components/Settings/SetupSection.tsx +6 -1
- package/components/Simulator/ScreenshotStrip.test.ts +91 -0
- package/components/Simulator/ScreenshotStrip.tsx +102 -0
- package/components/Simulator/index.ts +1 -0
- package/components/Tasks/TaskRow.tsx +2 -0
- package/components/Welcome/DevicesStep.tsx +90 -0
- package/components/Welcome/HealthList.tsx +69 -0
- package/components/Welcome/ProjectStep.tsx +2 -8
- package/components/Welcome/WelcomeDialog.tsx +31 -7
- package/data/doctor/index.ts +29 -0
- package/data/orchestrators/index.ts +59 -0
- package/data/push/topics.ts +2 -0
- package/data/simulator/index.test.ts +14 -0
- package/data/simulator/index.ts +33 -0
- package/data/simulator/keys.ts +5 -0
- package/hooks/useNeedsYou.ts +53 -0
- package/lib/chat/events.ts +2 -0
- package/lib/chat/find.ts +25 -4
- package/lib/chat/group.ts +50 -2
- package/lib/chat/turns.ts +111 -0
- package/lib/db/changes.ts +6 -0
- package/lib/db/migrations.ts +32 -0
- package/lib/doctor/checks.ts +222 -0
- package/lib/orchestrator/ask-view.ts +13 -0
- package/lib/orchestrator/brief.ts +5 -1
- package/lib/orchestrator/deliver.ts +3 -0
- package/lib/orchestrator/events.ts +19 -0
- package/lib/orchestrator/needs-you-items.ts +104 -0
- package/lib/orchestrator/overview.ts +3 -0
- package/lib/orchestrator/questions.ts +120 -0
- package/lib/orchestrator/serve.ts +11 -0
- package/lib/orchestrator/tool-names.ts +2 -0
- package/lib/orchestrator/tool-schemas.ts +12 -0
- package/lib/orchestrator/tools.ts +4 -0
- package/lib/welcome-url.ts +6 -1
- package/package.json +1 -1
- package/stores/needsYouFocus.ts +13 -0
- package/stores/welcomeUi.test.ts +15 -0
- 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
|
-
|
|
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: "
|
|
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'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",
|
|
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"
|