@saadnvd1/agent-os 0.3.520 → 0.3.521
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 +11 -0
- package/app/api/welcome/route.ts +22 -0
- package/app/page.tsx +6 -0
- package/app/welcome/page.tsx +6 -0
- package/components/Projects/AddProject/AddProjectDialog.tsx +2 -2
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Settings/SetupSection.tsx +37 -0
- package/components/Welcome/AgentList.tsx +73 -0
- package/components/Welcome/AgentRow.tsx +102 -0
- package/components/Welcome/ProjectStep.tsx +100 -0
- package/components/Welcome/SetupTerminal.tsx +64 -0
- package/components/Welcome/WelcomeDialog.tsx +153 -0
- package/data/agents/index.ts +16 -1
- package/data/welcome/index.ts +36 -0
- package/hooks/useWelcome.ts +70 -0
- package/lib/agents/probe.ts +27 -0
- package/lib/agents/setup-commands.ts +54 -0
- package/lib/db/migrations.ts +14 -0
- package/lib/settings-url.ts +1 -0
- package/lib/welcome-url.ts +33 -0
- package/lib/welcome.ts +88 -0
- package/package.json +1 -1
- package/stores/addProject.ts +19 -1
- package/stores/welcomeUi.ts +26 -0
package/README.md
CHANGED
|
@@ -213,6 +213,17 @@ its WSL note, and Windows reached it at both `localhost:3011` and
|
|
|
213
213
|
`wslview`/`explorer.exe`, mirrored networking, `agent-os enable` under
|
|
214
214
|
systemd, the idle-timeout settings above, and Debian or other distros.
|
|
215
215
|
|
|
216
|
+
### First run
|
|
217
|
+
|
|
218
|
+
On a fresh install (no sessions, no projects of your own) the web app opens a
|
|
219
|
+
short setup at `/?welcome`: it shows each agent CLI as ready, needing a
|
|
220
|
+
sign-in, or missing, installs or signs one in from an embedded terminal, adds
|
|
221
|
+
a first project (a folder, a clone or an empty one), then opens a session in
|
|
222
|
+
it. Every step after the first can be skipped. It is remembered on the server,
|
|
223
|
+
so a phone you pair later doesn't see it, and an install that already has work
|
|
224
|
+
never does. Reopen it from Settings → Setup (**Run setup again**) or at
|
|
225
|
+
`/welcome`; `AGENTOS_SKIP_WELCOME=1` turns it off.
|
|
226
|
+
|
|
216
227
|
### Supported agents
|
|
217
228
|
|
|
218
229
|
| Agent | Chat | Resume | Fork | Auto-approve |
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { demoMode } from "@/lib/security/demo";
|
|
3
|
+
import { completeWelcome, readWelcomeGate } from "@/lib/welcome";
|
|
4
|
+
|
|
5
|
+
// Whether the first-run welcome should open on this server. An install
|
|
6
|
+
// already in use is marked done here, so emptying it later doesn't bring it back.
|
|
7
|
+
export async function GET() {
|
|
8
|
+
return NextResponse.json(readWelcomeGate(demoMode()));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// { action: "complete" }: setup finished or skipped.
|
|
12
|
+
export async function POST(request: NextRequest) {
|
|
13
|
+
try {
|
|
14
|
+
const body = await request.json();
|
|
15
|
+
if (body?.action !== "complete") throw new Error("Unknown action");
|
|
16
|
+
completeWelcome(new Date().toISOString(), undefined, true);
|
|
17
|
+
return NextResponse.json(readWelcomeGate(demoMode()));
|
|
18
|
+
} catch (error) {
|
|
19
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
20
|
+
return NextResponse.json({ error: message }, { status: 400 });
|
|
21
|
+
}
|
|
22
|
+
}
|
package/app/page.tsx
CHANGED
|
@@ -78,6 +78,11 @@ const SettingsDialog = dynamic(
|
|
|
78
78
|
),
|
|
79
79
|
{ ssr: false }
|
|
80
80
|
);
|
|
81
|
+
const WelcomeDialog = dynamic(
|
|
82
|
+
() =>
|
|
83
|
+
import("@/components/Welcome/WelcomeDialog").then((m) => m.WelcomeDialog),
|
|
84
|
+
{ ssr: false }
|
|
85
|
+
);
|
|
81
86
|
const MessagesDialog = dynamic(
|
|
82
87
|
() => import("@/components/Bus/MessagesDialog").then((m) => m.MessagesDialog),
|
|
83
88
|
{ ssr: false }
|
|
@@ -122,6 +127,7 @@ const AppDialogs = memo(function AppDialogs() {
|
|
|
122
127
|
return (
|
|
123
128
|
<>
|
|
124
129
|
<TasksDialog />
|
|
130
|
+
<WelcomeDialog />
|
|
125
131
|
<AddProjectDialog />
|
|
126
132
|
<MessagesDialog />
|
|
127
133
|
<ArchivedDialog />
|
|
@@ -29,8 +29,8 @@ export function AddProjectDialog() {
|
|
|
29
29
|
const { open } = useSnapshot(addProjectUi);
|
|
30
30
|
const hostNames = useHostNames();
|
|
31
31
|
const done = (projectId: string) => {
|
|
32
|
-
addProjectActions.
|
|
33
|
-
|
|
32
|
+
if (!addProjectActions.finish(projectId))
|
|
33
|
+
newDraft({ kind: "project", projectId });
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
return (
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
Cpu,
|
|
12
12
|
GitMerge,
|
|
13
13
|
LayoutGrid,
|
|
14
|
+
ListChecks,
|
|
14
15
|
Smartphone,
|
|
15
16
|
X,
|
|
16
17
|
type LucideIcon,
|
|
@@ -34,6 +35,7 @@ import { SETTINGS_SECTIONS, type SettingsSection } from "@/lib/settings-url";
|
|
|
34
35
|
import { cn } from "@/lib/utils";
|
|
35
36
|
import { schedulesUiActions } from "@/stores/schedulesUi";
|
|
36
37
|
import { settingsUi, settingsUiActions } from "@/stores/settingsUi";
|
|
38
|
+
import { SetupSection } from "./SetupSection";
|
|
37
39
|
import {
|
|
38
40
|
NotificationsSection,
|
|
39
41
|
type BrowserNotifications,
|
|
@@ -78,6 +80,11 @@ export const SECTION_INFO: Record<
|
|
|
78
80
|
hint: "Where sessions spent tokens and repeated work",
|
|
79
81
|
icon: ClipboardList,
|
|
80
82
|
},
|
|
83
|
+
setup: {
|
|
84
|
+
label: "Setup",
|
|
85
|
+
hint: "Agents on this machine, first-run setup",
|
|
86
|
+
icon: ListChecks,
|
|
87
|
+
},
|
|
81
88
|
};
|
|
82
89
|
|
|
83
90
|
function SectionBody({
|
|
@@ -102,6 +109,8 @@ function SectionBody({
|
|
|
102
109
|
return <SchedulesPanel />;
|
|
103
110
|
case "audits":
|
|
104
111
|
return <AuditsPanel />;
|
|
112
|
+
case "setup":
|
|
113
|
+
return <SetupSection />;
|
|
105
114
|
}
|
|
106
115
|
}
|
|
107
116
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import { AgentList } from "@/components/Welcome/AgentList";
|
|
5
|
+
import { settingsUiActions } from "@/stores/settingsUi";
|
|
6
|
+
import { welcomeUiActions } from "@/stores/welcomeUi";
|
|
7
|
+
|
|
8
|
+
// What this machine has for running agents, and the first-run setup again.
|
|
9
|
+
export function SetupSection() {
|
|
10
|
+
return (
|
|
11
|
+
<div className="flex flex-col gap-4">
|
|
12
|
+
<div className="bg-muted/40 flex items-center gap-3 rounded-lg px-3 py-2.5">
|
|
13
|
+
<div className="min-w-0 flex-1">
|
|
14
|
+
<p className="text-sm font-medium">First-run setup</p>
|
|
15
|
+
<p className="text-muted-foreground text-xs">
|
|
16
|
+
Agents, your first project, and a first session.
|
|
17
|
+
</p>
|
|
18
|
+
</div>
|
|
19
|
+
<Button
|
|
20
|
+
size="sm"
|
|
21
|
+
variant="outline"
|
|
22
|
+
className="min-h-11 sm:min-h-8"
|
|
23
|
+
onClick={() => {
|
|
24
|
+
settingsUiActions.close();
|
|
25
|
+
welcomeUiActions.open("agents");
|
|
26
|
+
}}
|
|
27
|
+
>
|
|
28
|
+
Run setup again
|
|
29
|
+
</Button>
|
|
30
|
+
</div>
|
|
31
|
+
<div className="flex flex-col gap-2">
|
|
32
|
+
<h3 className="text-sm font-medium">Agents on this machine</h3>
|
|
33
|
+
<AgentList />
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { RefreshCw } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { useAgentStatusQuery, useRecheckAgents } from "@/data/agents";
|
|
7
|
+
import { FEATURED_AGENTS } from "@/lib/agents/setup-commands";
|
|
8
|
+
import { getAllProviderDefinitions } from "@/lib/providers/registry";
|
|
9
|
+
import { AgentRow, type AgentRun } from "./AgentRow";
|
|
10
|
+
import { SetupTerminal } from "./SetupTerminal";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Every agent CLI on this machine with what it needs, and an embedded
|
|
14
|
+
* terminal to install or sign one in. Checks again when it opens and when a
|
|
15
|
+
* terminal is closed, never on a timer.
|
|
16
|
+
*/
|
|
17
|
+
export function AgentList() {
|
|
18
|
+
const { data: agents } = useAgentStatusQuery();
|
|
19
|
+
const recheck = useRecheckAgents();
|
|
20
|
+
const [run, setRun] = useState<(AgentRun & { key: number }) | null>(null);
|
|
21
|
+
const [all, setAll] = useState(false);
|
|
22
|
+
const { mutate } = recheck;
|
|
23
|
+
|
|
24
|
+
useEffect(() => mutate(), [mutate]);
|
|
25
|
+
|
|
26
|
+
const providers = getAllProviderDefinitions().filter((p) => p.cli);
|
|
27
|
+
const shown = providers.filter((p) => all || FEATURED_AGENTS.includes(p.id));
|
|
28
|
+
const hidden = providers.length - shown.length;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="flex flex-col gap-3">
|
|
32
|
+
<ul className="flex flex-col gap-2">
|
|
33
|
+
{shown.map((p) => (
|
|
34
|
+
<AgentRow
|
|
35
|
+
key={p.id}
|
|
36
|
+
provider={p}
|
|
37
|
+
probe={agents?.[p.id]}
|
|
38
|
+
onRun={(r) => setRun({ ...r, key: Date.now() })}
|
|
39
|
+
/>
|
|
40
|
+
))}
|
|
41
|
+
</ul>
|
|
42
|
+
{run && (
|
|
43
|
+
<SetupTerminal
|
|
44
|
+
key={run.key}
|
|
45
|
+
title={run.title}
|
|
46
|
+
command={run.command}
|
|
47
|
+
onDone={() => {
|
|
48
|
+
setRun(null);
|
|
49
|
+
mutate();
|
|
50
|
+
}}
|
|
51
|
+
/>
|
|
52
|
+
)}
|
|
53
|
+
<div className="flex items-center justify-between gap-2">
|
|
54
|
+
{hidden > 0 ? (
|
|
55
|
+
<Button size="sm" variant="ghost" onClick={() => setAll(true)}>
|
|
56
|
+
Show {hidden} more
|
|
57
|
+
</Button>
|
|
58
|
+
) : (
|
|
59
|
+
<span />
|
|
60
|
+
)}
|
|
61
|
+
<Button
|
|
62
|
+
size="sm"
|
|
63
|
+
variant="ghost"
|
|
64
|
+
disabled={recheck.isPending}
|
|
65
|
+
onClick={() => mutate()}
|
|
66
|
+
>
|
|
67
|
+
<RefreshCw className={recheck.isPending ? "animate-spin" : ""} />
|
|
68
|
+
Check again
|
|
69
|
+
</Button>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Check, Download, LogIn } from "lucide-react";
|
|
4
|
+
import { Badge } from "@/components/ui/badge";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import type { AgentProbe } from "@/lib/agents/probe";
|
|
7
|
+
import { agentState, SETUP_COMMANDS } from "@/lib/agents/setup-commands";
|
|
8
|
+
import type { ProviderDefinition } from "@/lib/providers/registry";
|
|
9
|
+
|
|
10
|
+
const LABEL = {
|
|
11
|
+
missing: "Not installed",
|
|
12
|
+
"needs-login": "Needs sign-in",
|
|
13
|
+
ready: "Ready",
|
|
14
|
+
installed: "Installed",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export type AgentRun = { title: string; command: string };
|
|
18
|
+
|
|
19
|
+
/** One agent CLI: its state, and the install or sign-in it needs. */
|
|
20
|
+
export function AgentRow({
|
|
21
|
+
provider,
|
|
22
|
+
probe,
|
|
23
|
+
onRun,
|
|
24
|
+
}: {
|
|
25
|
+
provider: ProviderDefinition;
|
|
26
|
+
probe: AgentProbe | undefined;
|
|
27
|
+
onRun: (run: AgentRun) => void;
|
|
28
|
+
}) {
|
|
29
|
+
const state = probe ? agentState(probe) : null;
|
|
30
|
+
const commands = SETUP_COMMANDS[provider.id];
|
|
31
|
+
return (
|
|
32
|
+
<li className="bg-muted/40 flex min-h-11 items-center gap-3 rounded-lg px-3 py-2">
|
|
33
|
+
<div className="min-w-0 flex-1">
|
|
34
|
+
<p className="truncate text-sm font-medium">{provider.name}</p>
|
|
35
|
+
<p className="text-muted-foreground truncate text-xs">
|
|
36
|
+
{probe?.version
|
|
37
|
+
? `v${probe.version}`
|
|
38
|
+
: (probe?.hint ?? provider.description)}
|
|
39
|
+
</p>
|
|
40
|
+
</div>
|
|
41
|
+
{!state && <Badge variant="outline">Checking</Badge>}
|
|
42
|
+
{state === "ready" && (
|
|
43
|
+
<Badge variant="success">
|
|
44
|
+
<Check className="mr-1 size-3" />
|
|
45
|
+
{LABEL.ready}
|
|
46
|
+
</Badge>
|
|
47
|
+
)}
|
|
48
|
+
{state === "installed" && <Badge variant="outline">{LABEL[state]}</Badge>}
|
|
49
|
+
{state === "needs-login" && (
|
|
50
|
+
<>
|
|
51
|
+
<Badge variant="secondary">{LABEL[state]}</Badge>
|
|
52
|
+
{commands?.signIn && (
|
|
53
|
+
<Button
|
|
54
|
+
size="sm"
|
|
55
|
+
variant="outline"
|
|
56
|
+
className="min-h-11 sm:min-h-8"
|
|
57
|
+
onClick={() =>
|
|
58
|
+
onRun({
|
|
59
|
+
title: `Sign in to ${provider.name}`,
|
|
60
|
+
command: commands.signIn!,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
>
|
|
64
|
+
<LogIn />
|
|
65
|
+
Sign in
|
|
66
|
+
</Button>
|
|
67
|
+
)}
|
|
68
|
+
</>
|
|
69
|
+
)}
|
|
70
|
+
{state === "missing" &&
|
|
71
|
+
(commands?.install ? (
|
|
72
|
+
<Button
|
|
73
|
+
size="sm"
|
|
74
|
+
variant="outline"
|
|
75
|
+
className="min-h-11 sm:min-h-8"
|
|
76
|
+
onClick={() =>
|
|
77
|
+
onRun({
|
|
78
|
+
title: `Install ${provider.name}`,
|
|
79
|
+
command: commands.install!,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
>
|
|
83
|
+
<Download />
|
|
84
|
+
Install
|
|
85
|
+
</Button>
|
|
86
|
+
) : commands?.docs ? (
|
|
87
|
+
<Button
|
|
88
|
+
size="sm"
|
|
89
|
+
variant="outline"
|
|
90
|
+
className="min-h-11 sm:min-h-8"
|
|
91
|
+
asChild
|
|
92
|
+
>
|
|
93
|
+
<a href={commands.docs} target="_blank" rel="noreferrer">
|
|
94
|
+
How to install
|
|
95
|
+
</a>
|
|
96
|
+
</Button>
|
|
97
|
+
) : (
|
|
98
|
+
<Badge variant="outline">{LABEL.missing}</Badge>
|
|
99
|
+
))}
|
|
100
|
+
</li>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { FolderGit2, FolderOpen, FolderPlus, Check } from "lucide-react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { useProjectsQuery } from "@/data/projects/queries";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import { addProjectActions, type AddProjectKind } from "@/stores/addProject";
|
|
8
|
+
import { welcomeUi, welcomeUiActions } from "@/stores/welcomeUi";
|
|
9
|
+
import { useSnapshot } from "valtio";
|
|
10
|
+
|
|
11
|
+
const WAYS: {
|
|
12
|
+
kind: AddProjectKind;
|
|
13
|
+
title: string;
|
|
14
|
+
hint: string;
|
|
15
|
+
icon: typeof FolderOpen;
|
|
16
|
+
}[] = [
|
|
17
|
+
{
|
|
18
|
+
kind: "folder",
|
|
19
|
+
title: "Open a folder",
|
|
20
|
+
hint: "A project already on this machine",
|
|
21
|
+
icon: FolderOpen,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
kind: "clone",
|
|
25
|
+
title: "Clone a repo",
|
|
26
|
+
hint: "From a Git URL",
|
|
27
|
+
icon: FolderGit2,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
kind: "name",
|
|
31
|
+
title: "Start an empty project",
|
|
32
|
+
hint: "A new folder with a name",
|
|
33
|
+
icon: FolderPlus,
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** Add the project the first session will open in, or pick one already there. */
|
|
38
|
+
export function ProjectStep() {
|
|
39
|
+
const { projectId } = useSnapshot(welcomeUi);
|
|
40
|
+
const { data: projects = [] } = useProjectsQuery();
|
|
41
|
+
const own = projects.filter((p) => !p.is_uncategorized);
|
|
42
|
+
|
|
43
|
+
const add = (kind: AddProjectKind) =>
|
|
44
|
+
addProjectActions.show(kind, "local", (id) => {
|
|
45
|
+
welcomeUiActions.setProject(id);
|
|
46
|
+
welcomeUiActions.go("finish");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="flex flex-col gap-3">
|
|
51
|
+
{own.length > 0 && (
|
|
52
|
+
<ul className="flex flex-col gap-2">
|
|
53
|
+
{own.map((p) => (
|
|
54
|
+
<li key={p.id}>
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
onClick={() => {
|
|
58
|
+
welcomeUiActions.setProject(p.id);
|
|
59
|
+
welcomeUiActions.go("finish");
|
|
60
|
+
}}
|
|
61
|
+
className={cn(
|
|
62
|
+
"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
|
+
projectId === p.id && "ring-primary/40 ring-2"
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
<span className="min-w-0 flex-1">
|
|
67
|
+
<span className="block truncate text-sm font-medium">
|
|
68
|
+
{p.name}
|
|
69
|
+
</span>
|
|
70
|
+
<span className="text-muted-foreground block truncate text-xs">
|
|
71
|
+
{p.working_directory}
|
|
72
|
+
</span>
|
|
73
|
+
</span>
|
|
74
|
+
{projectId === p.id && <Check className="size-4" />}
|
|
75
|
+
</button>
|
|
76
|
+
</li>
|
|
77
|
+
))}
|
|
78
|
+
</ul>
|
|
79
|
+
)}
|
|
80
|
+
<div className="flex flex-col gap-2">
|
|
81
|
+
{WAYS.map((w) => (
|
|
82
|
+
<Button
|
|
83
|
+
key={w.kind}
|
|
84
|
+
variant="outline"
|
|
85
|
+
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
|
|
86
|
+
onClick={() => add(w.kind)}
|
|
87
|
+
>
|
|
88
|
+
<w.icon />
|
|
89
|
+
<span className="min-w-0">
|
|
90
|
+
<span className="block text-sm font-medium">{w.title}</span>
|
|
91
|
+
<span className="text-muted-foreground block text-xs font-normal">
|
|
92
|
+
{w.hint}
|
|
93
|
+
</span>
|
|
94
|
+
</span>
|
|
95
|
+
</Button>
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
4
|
+
import dynamic from "next/dynamic";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import type { TerminalHandle } from "@/components/Terminal";
|
|
7
|
+
|
|
8
|
+
const Terminal = dynamic(
|
|
9
|
+
() => import("@/components/Terminal").then((m) => m.Terminal),
|
|
10
|
+
{ ssr: false }
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A terminal on this machine with one setup command typed at the prompt, for
|
|
15
|
+
* the user to read and run with Enter. It goes into their own shell over the
|
|
16
|
+
* existing terminal socket; the server runs nothing.
|
|
17
|
+
*/
|
|
18
|
+
export function SetupTerminal({
|
|
19
|
+
title,
|
|
20
|
+
command,
|
|
21
|
+
onDone,
|
|
22
|
+
}: {
|
|
23
|
+
title: string;
|
|
24
|
+
command: string;
|
|
25
|
+
onDone: () => void;
|
|
26
|
+
}) {
|
|
27
|
+
const ref = useRef<TerminalHandle | null>(null);
|
|
28
|
+
const typed = useRef(false);
|
|
29
|
+
const box = useRef<HTMLDivElement>(null);
|
|
30
|
+
|
|
31
|
+
// On a phone it opens below the list, out of sight.
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
box.current?.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
const connected = useCallback(() => {
|
|
37
|
+
if (typed.current) return;
|
|
38
|
+
typed.current = true;
|
|
39
|
+
setTimeout(() => {
|
|
40
|
+
ref.current?.sendInput("\x15");
|
|
41
|
+
// Typed, not run: the user reads it and presses Enter.
|
|
42
|
+
setTimeout(() => ref.current?.sendInput(command), 50);
|
|
43
|
+
}, 100);
|
|
44
|
+
}, [command]);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div
|
|
48
|
+
ref={box}
|
|
49
|
+
className="bg-muted/30 hairline flex flex-col overflow-hidden rounded-lg"
|
|
50
|
+
>
|
|
51
|
+
<div className="flex items-center justify-between gap-2 px-3 py-1.5">
|
|
52
|
+
<span className="text-muted-foreground truncate text-xs">
|
|
53
|
+
{title}. Press Enter to run it.
|
|
54
|
+
</span>
|
|
55
|
+
<Button size="sm" onClick={onDone}>
|
|
56
|
+
Done, check again
|
|
57
|
+
</Button>
|
|
58
|
+
</div>
|
|
59
|
+
<div className="h-64 min-h-0">
|
|
60
|
+
<Terminal ref={ref} onConnected={connected} />
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { memo } from "react";
|
|
4
|
+
import { useSnapshot } from "valtio";
|
|
5
|
+
import { Check } from "lucide-react";
|
|
6
|
+
import {
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogDescription,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
} from "@/components/ui/dialog";
|
|
12
|
+
import { Button } from "@/components/ui/button";
|
|
13
|
+
import { useProjectsQuery } from "@/data/projects/queries";
|
|
14
|
+
import { useEndWelcome, useWelcomeUrl } from "@/hooks/useWelcome";
|
|
15
|
+
import { cn } from "@/lib/utils";
|
|
16
|
+
import { WELCOME_STEPS, type WelcomeStep } from "@/lib/welcome-url";
|
|
17
|
+
import { welcomeUi, welcomeUiActions } from "@/stores/welcomeUi";
|
|
18
|
+
import { AgentList } from "./AgentList";
|
|
19
|
+
import { ProjectStep } from "./ProjectStep";
|
|
20
|
+
|
|
21
|
+
const STEPS: Record<
|
|
22
|
+
WelcomeStep,
|
|
23
|
+
{ label: string; title: string; hint: string }
|
|
24
|
+
> = {
|
|
25
|
+
agents: {
|
|
26
|
+
label: "Agents",
|
|
27
|
+
title: "Connect your agents",
|
|
28
|
+
hint: "Install and sign in the coding agents you want to use. You need one.",
|
|
29
|
+
},
|
|
30
|
+
project: {
|
|
31
|
+
label: "Project",
|
|
32
|
+
title: "Add your first project",
|
|
33
|
+
hint: "A folder or repo for the first session to work in.",
|
|
34
|
+
},
|
|
35
|
+
finish: {
|
|
36
|
+
label: "Start",
|
|
37
|
+
title: "You're set",
|
|
38
|
+
hint: "Open a session in your project and give it a task.",
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function FinishStep() {
|
|
43
|
+
const { projectId } = useSnapshot(welcomeUi);
|
|
44
|
+
const { data: projects = [] } = useProjectsQuery();
|
|
45
|
+
const project = projects.find((p) => p.id === projectId);
|
|
46
|
+
return (
|
|
47
|
+
<p className="text-muted-foreground bg-muted/40 rounded-lg px-3 py-3 text-sm">
|
|
48
|
+
{project ? (
|
|
49
|
+
<>
|
|
50
|
+
First session opens in{" "}
|
|
51
|
+
<span className="text-foreground font-medium">{project.name}</span>.
|
|
52
|
+
</>
|
|
53
|
+
) : (
|
|
54
|
+
"No project yet. Add one from the sidebar whenever you are ready."
|
|
55
|
+
)}
|
|
56
|
+
</p>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* First-run setup: agents, then a first project, then a session. It stays
|
|
62
|
+
* open until finished or skipped, and every step after the first can be skipped.
|
|
63
|
+
*/
|
|
64
|
+
export const WelcomeDialog = memo(function WelcomeDialog() {
|
|
65
|
+
useWelcomeUrl();
|
|
66
|
+
const { open, step, projectId } = useSnapshot(welcomeUi);
|
|
67
|
+
const end = useEndWelcome();
|
|
68
|
+
const at = WELCOME_STEPS.indexOf(step);
|
|
69
|
+
const info = STEPS[step];
|
|
70
|
+
const last = at === WELCOME_STEPS.length - 1;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<Dialog open={open}>
|
|
74
|
+
<DialogContent
|
|
75
|
+
showCloseButton={false}
|
|
76
|
+
onInteractOutside={(e) => e.preventDefault()}
|
|
77
|
+
onEscapeKeyDown={(e) => e.preventDefault()}
|
|
78
|
+
className="max-h-[100dvh] overflow-y-auto max-sm:h-[100dvh] max-sm:max-w-full max-sm:rounded-none sm:max-h-[90dvh] sm:max-w-xl"
|
|
79
|
+
>
|
|
80
|
+
<nav aria-label="Setup steps" className="flex items-center gap-1">
|
|
81
|
+
{WELCOME_STEPS.map((s, i) => (
|
|
82
|
+
<button
|
|
83
|
+
key={s}
|
|
84
|
+
type="button"
|
|
85
|
+
disabled={i === at}
|
|
86
|
+
onClick={() => welcomeUiActions.go(s)}
|
|
87
|
+
aria-current={i === at ? "step" : undefined}
|
|
88
|
+
className={cn(
|
|
89
|
+
"flex min-h-11 flex-1 items-center justify-center gap-1.5 rounded-lg px-2 text-xs font-medium sm:min-h-8",
|
|
90
|
+
i === at
|
|
91
|
+
? "bg-foreground/[0.08] text-foreground"
|
|
92
|
+
: "text-muted-foreground hover:bg-foreground/[0.06]"
|
|
93
|
+
)}
|
|
94
|
+
>
|
|
95
|
+
{i < at && <Check className="size-3" />}
|
|
96
|
+
{STEPS[s].label}
|
|
97
|
+
</button>
|
|
98
|
+
))}
|
|
99
|
+
<Button
|
|
100
|
+
variant="ghost"
|
|
101
|
+
size="sm"
|
|
102
|
+
className="min-h-11 sm:min-h-8"
|
|
103
|
+
onClick={() => end()}
|
|
104
|
+
>
|
|
105
|
+
Skip setup
|
|
106
|
+
</Button>
|
|
107
|
+
</nav>
|
|
108
|
+
<div className="flex flex-col gap-1">
|
|
109
|
+
<DialogTitle>{info.title}</DialogTitle>
|
|
110
|
+
<DialogDescription>{info.hint}</DialogDescription>
|
|
111
|
+
</div>
|
|
112
|
+
{step === "agents" && <AgentList />}
|
|
113
|
+
{step === "project" && <ProjectStep />}
|
|
114
|
+
{step === "finish" && <FinishStep />}
|
|
115
|
+
<div className="flex items-center justify-between gap-2">
|
|
116
|
+
<Button
|
|
117
|
+
variant="ghost"
|
|
118
|
+
className="min-h-11 sm:min-h-9"
|
|
119
|
+
disabled={at === 0}
|
|
120
|
+
onClick={() => welcomeUiActions.go(WELCOME_STEPS[at - 1])}
|
|
121
|
+
>
|
|
122
|
+
Back
|
|
123
|
+
</Button>
|
|
124
|
+
<div className="flex items-center gap-2">
|
|
125
|
+
{step === "project" && (
|
|
126
|
+
<Button
|
|
127
|
+
variant="ghost"
|
|
128
|
+
className="min-h-11 sm:min-h-9"
|
|
129
|
+
onClick={() => welcomeUiActions.go("finish")}
|
|
130
|
+
>
|
|
131
|
+
Skip
|
|
132
|
+
</Button>
|
|
133
|
+
)}
|
|
134
|
+
<Button
|
|
135
|
+
className="min-h-11 sm:min-h-9"
|
|
136
|
+
onClick={() =>
|
|
137
|
+
last ? end(true) : welcomeUiActions.go(WELCOME_STEPS[at + 1])
|
|
138
|
+
}
|
|
139
|
+
>
|
|
140
|
+
{last
|
|
141
|
+
? projectId
|
|
142
|
+
? "Start your first session"
|
|
143
|
+
: "Go to AgentOS"
|
|
144
|
+
: step === "agents"
|
|
145
|
+
? "Continue"
|
|
146
|
+
: "Next"}
|
|
147
|
+
</Button>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
</DialogContent>
|
|
151
|
+
</Dialog>
|
|
152
|
+
);
|
|
153
|
+
});
|
package/data/agents/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useQuery } from "@tanstack/react-query";
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
2
|
import type { AgentProbe } from "@/lib/agents/probe";
|
|
3
3
|
|
|
4
4
|
export const agentKeys = {
|
|
@@ -20,3 +20,18 @@ export function useAgentStatusQuery() {
|
|
|
20
20
|
refetchOnMount: "always",
|
|
21
21
|
});
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
// Asks the server to probe again now, not from its minute-old answer, and
|
|
25
|
+
// puts the result where the status query reads it.
|
|
26
|
+
export function useRecheckAgents() {
|
|
27
|
+
const client = useQueryClient();
|
|
28
|
+
return useMutation({
|
|
29
|
+
mutationFn: async () => {
|
|
30
|
+
const res = await fetch("/api/agents/status?fresh");
|
|
31
|
+
if (!res.ok) throw new Error("Couldn't check the agents");
|
|
32
|
+
return ((await res.json()) as { agents: Record<string, AgentProbe> })
|
|
33
|
+
.agents;
|
|
34
|
+
},
|
|
35
|
+
onSuccess: (agents) => client.setQueryData(agentKeys.status(), agents),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { WelcomeGate } from "@/lib/welcome";
|
|
3
|
+
|
|
4
|
+
export const welcomeKeys = {
|
|
5
|
+
all: ["welcome"] as const,
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function useWelcomeQuery() {
|
|
9
|
+
return useQuery({
|
|
10
|
+
queryKey: welcomeKeys.all,
|
|
11
|
+
queryFn: async () => {
|
|
12
|
+
const res = await fetch("/api/welcome");
|
|
13
|
+
if (!res.ok) throw new Error("Couldn't check first-run setup");
|
|
14
|
+
return (await res.json()) as WelcomeGate;
|
|
15
|
+
},
|
|
16
|
+
// Asked once per load: it only flips when setup is done here.
|
|
17
|
+
staleTime: Infinity,
|
|
18
|
+
retry: false,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useCompleteWelcome() {
|
|
23
|
+
const client = useQueryClient();
|
|
24
|
+
return useMutation({
|
|
25
|
+
mutationFn: async () => {
|
|
26
|
+
const res = await fetch("/api/welcome", {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { "content-type": "application/json" },
|
|
29
|
+
body: JSON.stringify({ action: "complete" }),
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok) throw new Error("Couldn't save setup");
|
|
32
|
+
return (await res.json()) as WelcomeGate;
|
|
33
|
+
},
|
|
34
|
+
onSuccess: (gate) => client.setQueryData(welcomeKeys.all, gate),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { subscribe } from "valtio";
|
|
5
|
+
import { useCompleteWelcome, useWelcomeQuery } from "@/data/welcome";
|
|
6
|
+
import { hrefWithWelcome, welcomeFromHref } from "@/lib/welcome-url";
|
|
7
|
+
import { newDraft } from "@/stores/drafts";
|
|
8
|
+
import { welcomeUi, welcomeUiActions } from "@/stores/welcomeUi";
|
|
9
|
+
|
|
10
|
+
function fromAddress() {
|
|
11
|
+
const at = welcomeFromHref(window.location.href);
|
|
12
|
+
if (at.open) welcomeUiActions.open(at.step);
|
|
13
|
+
else if (welcomeUi.open) welcomeUiActions.close();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `?welcome=<step>` opens the first-run welcome at that step, on load and on
|
|
18
|
+
* back/forward; the address follows it. Moving between steps adds a history
|
|
19
|
+
* entry, so Back goes to the step before. A fresh install with setup still to
|
|
20
|
+
* do opens it by itself, once.
|
|
21
|
+
*/
|
|
22
|
+
export function useWelcomeUrl() {
|
|
23
|
+
const { data: gate } = useWelcomeQuery();
|
|
24
|
+
const asked = useRef(false);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
fromAddress();
|
|
28
|
+
let was = { open: welcomeUi.open, step: welcomeUi.step };
|
|
29
|
+
const unsubscribe = subscribe(welcomeUi, () => {
|
|
30
|
+
const href = window.location.href;
|
|
31
|
+
const next = hrefWithWelcome(href, {
|
|
32
|
+
open: welcomeUi.open,
|
|
33
|
+
step: welcomeUi.step,
|
|
34
|
+
});
|
|
35
|
+
const url = new URL(href);
|
|
36
|
+
if (next !== url.pathname + url.search + url.hash) {
|
|
37
|
+
const move = was.open && welcomeUi.open && was.step !== welcomeUi.step;
|
|
38
|
+
window.history[move ? "pushState" : "replaceState"](
|
|
39
|
+
window.history.state,
|
|
40
|
+
"",
|
|
41
|
+
next
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
was = { open: welcomeUi.open, step: welcomeUi.step };
|
|
45
|
+
});
|
|
46
|
+
window.addEventListener("popstate", fromAddress);
|
|
47
|
+
return () => {
|
|
48
|
+
unsubscribe();
|
|
49
|
+
window.removeEventListener("popstate", fromAddress);
|
|
50
|
+
};
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!gate?.show || asked.current || welcomeUi.open) return;
|
|
55
|
+
asked.current = true;
|
|
56
|
+
welcomeUiActions.open("agents");
|
|
57
|
+
}, [gate?.show]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Setup is over, finished or skipped: remember it and close. */
|
|
61
|
+
export function useEndWelcome() {
|
|
62
|
+
const { mutate } = useCompleteWelcome();
|
|
63
|
+
return (start?: boolean) => {
|
|
64
|
+
const projectId = welcomeUi.projectId;
|
|
65
|
+
mutate();
|
|
66
|
+
welcomeUiActions.close();
|
|
67
|
+
welcomeUiActions.setProject(null);
|
|
68
|
+
if (start && projectId) newDraft({ kind: "project", projectId });
|
|
69
|
+
};
|
|
70
|
+
}
|
package/lib/agents/probe.ts
CHANGED
|
@@ -64,7 +64,34 @@ type AuthCheck = (
|
|
|
64
64
|
env: NodeJS.ProcessEnv
|
|
65
65
|
) => Promise<AgentAuth> | AgentAuth;
|
|
66
66
|
|
|
67
|
+
// Claude Code keeps who is signed in under `oauthAccount` in its config, at
|
|
68
|
+
// $CLAUDE_CONFIG_DIR/.claude.json, or ~/.claude.json without that variable.
|
|
69
|
+
function claudeAuth(env: NodeJS.ProcessEnv): AgentAuth {
|
|
70
|
+
const dir = env.CLAUDE_CONFIG_DIR?.trim();
|
|
71
|
+
const configDir = dir || home(".claude");
|
|
72
|
+
const config = dir ? path.join(dir, ".claude.json") : home(".claude.json");
|
|
73
|
+
if (
|
|
74
|
+
anyEnv(env, [
|
|
75
|
+
"ANTHROPIC_API_KEY",
|
|
76
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
77
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
78
|
+
"CLAUDE_CODE_USE_BEDROCK",
|
|
79
|
+
"CLAUDE_CODE_USE_VERTEX",
|
|
80
|
+
]) ||
|
|
81
|
+
hasJsonKeys(path.join(configDir, "settings.json"), "apiKeyHelper")
|
|
82
|
+
)
|
|
83
|
+
return "ready";
|
|
84
|
+
if (hasJsonKeys(config, "oauthAccount")) return "ready";
|
|
85
|
+
// A config without an account is a CLI that has never signed in; no config
|
|
86
|
+
// at all tells nothing.
|
|
87
|
+
return hasJsonKeys(config) ? "needs-login" : "unknown";
|
|
88
|
+
}
|
|
89
|
+
|
|
67
90
|
const AUTH: Partial<Record<ProviderId, { check: AuthCheck; hint: string }>> = {
|
|
91
|
+
claude: {
|
|
92
|
+
check: (_cli, env) => claudeAuth(env),
|
|
93
|
+
hint: "Run `claude auth login` in a terminal",
|
|
94
|
+
},
|
|
68
95
|
codex: {
|
|
69
96
|
// Exits 0 signed in, 1 not.
|
|
70
97
|
check: async (cli, env) => {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The official commands the welcome runs in its terminal to install an agent
|
|
3
|
+
* CLI or sign it in. Typed into the user's own shell, never run by the
|
|
4
|
+
* server, so they are the same lines the install script (scripts/lib/ai-clis.sh)
|
|
5
|
+
* uses.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ProviderId } from "../providers/registry";
|
|
9
|
+
|
|
10
|
+
export interface SetupCommands {
|
|
11
|
+
install?: string;
|
|
12
|
+
signIn?: string;
|
|
13
|
+
docs?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const SETUP_COMMANDS: Partial<Record<ProviderId, SetupCommands>> = {
|
|
17
|
+
claude: {
|
|
18
|
+
install: "curl -fsSL https://claude.ai/install.sh | bash",
|
|
19
|
+
signIn: "claude auth login",
|
|
20
|
+
},
|
|
21
|
+
codex: {
|
|
22
|
+
install: "npm install -g @openai/codex",
|
|
23
|
+
signIn: "codex login",
|
|
24
|
+
},
|
|
25
|
+
gemini: {
|
|
26
|
+
install: "npm install -g @google/gemini-cli",
|
|
27
|
+
signIn: "gemini",
|
|
28
|
+
},
|
|
29
|
+
kilocode: { install: "npm install -g @kilocode/cli", signIn: "kilo" },
|
|
30
|
+
cursor: { signIn: "cursor-agent login", docs: "https://cursor.com/cli" },
|
|
31
|
+
amp: { signIn: "amp login", docs: "https://ampcode.com" },
|
|
32
|
+
pi: { signIn: "pi" },
|
|
33
|
+
// No installer line we can vouch for from here; its own site has it.
|
|
34
|
+
opencode: { docs: "https://opencode.ai/docs" },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// The ones the welcome shows up front; the rest wait behind "Show all".
|
|
38
|
+
export const FEATURED_AGENTS: ProviderId[] = [
|
|
39
|
+
"claude",
|
|
40
|
+
"codex",
|
|
41
|
+
"gemini",
|
|
42
|
+
"opencode",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export type AgentState = "missing" | "needs-login" | "ready" | "installed";
|
|
46
|
+
|
|
47
|
+
export function agentState(p: {
|
|
48
|
+
installed: boolean;
|
|
49
|
+
auth: "ready" | "needs-login" | "unknown";
|
|
50
|
+
}): AgentState {
|
|
51
|
+
if (!p.installed) return "missing";
|
|
52
|
+
if (p.auth === "needs-login") return "needs-login";
|
|
53
|
+
return p.auth === "ready" ? "ready" : "installed";
|
|
54
|
+
}
|
package/lib/db/migrations.ts
CHANGED
|
@@ -1754,6 +1754,20 @@ const migrations: Migration[] = [
|
|
|
1754
1754
|
db.exec(`ALTER TABLE ${table} ADD COLUMN plan_only INTEGER`);
|
|
1755
1755
|
},
|
|
1756
1756
|
},
|
|
1757
|
+
{
|
|
1758
|
+
id: 76,
|
|
1759
|
+
name: "welcome_backfill",
|
|
1760
|
+
up: (db) => {
|
|
1761
|
+
// An install that already has a session or a project of its own has
|
|
1762
|
+
// been set up; mark it so the first-run welcome never shows there.
|
|
1763
|
+
db.exec(
|
|
1764
|
+
`INSERT OR IGNORE INTO settings (key, value)
|
|
1765
|
+
SELECT 'welcome.completed_at', 'existing-install'
|
|
1766
|
+
WHERE EXISTS (SELECT 1 FROM sessions)
|
|
1767
|
+
OR EXISTS (SELECT 1 FROM projects WHERE is_uncategorized = 0)`
|
|
1768
|
+
);
|
|
1769
|
+
},
|
|
1770
|
+
},
|
|
1757
1771
|
];
|
|
1758
1772
|
|
|
1759
1773
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/settings-url.ts
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The welcome has its own address: `/?welcome=<step>`, beside `?settings=`
|
|
2
|
+
// and `?session=`. `/welcome` redirects here.
|
|
3
|
+
|
|
4
|
+
export const WELCOME_PARAM = "welcome";
|
|
5
|
+
|
|
6
|
+
export const WELCOME_STEPS = ["agents", "project", "finish"] as const;
|
|
7
|
+
|
|
8
|
+
export type WelcomeStep = (typeof WELCOME_STEPS)[number];
|
|
9
|
+
|
|
10
|
+
export interface WelcomeLocation {
|
|
11
|
+
open: boolean;
|
|
12
|
+
step: WelcomeStep;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const BASE = "http://agentos.invalid";
|
|
16
|
+
|
|
17
|
+
export const isWelcomeStep = (v: unknown): v is WelcomeStep =>
|
|
18
|
+
(WELCOME_STEPS as readonly unknown[]).includes(v);
|
|
19
|
+
|
|
20
|
+
/** What the address says; a bare or unknown step starts at the first. */
|
|
21
|
+
export function welcomeFromHref(href: string): WelcomeLocation {
|
|
22
|
+
const value = new URL(href, BASE).searchParams.get(WELCOME_PARAM);
|
|
23
|
+
if (value === null) return { open: false, step: WELCOME_STEPS[0] };
|
|
24
|
+
return { open: true, step: isWelcomeStep(value) ? value : WELCOME_STEPS[0] };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The same address with the welcome set (or removed), as path+query+hash. */
|
|
28
|
+
export function hrefWithWelcome(href: string, at: WelcomeLocation): string {
|
|
29
|
+
const url = new URL(href, BASE);
|
|
30
|
+
if (at.open) url.searchParams.set(WELCOME_PARAM, at.step);
|
|
31
|
+
else url.searchParams.delete(WELCOME_PARAM);
|
|
32
|
+
return url.pathname + url.search + url.hash;
|
|
33
|
+
}
|
package/lib/welcome.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-run welcome: shown once on a fresh install, never on one that
|
|
3
|
+
* already has work. Completion is the server's (one server, many devices),
|
|
4
|
+
* kept in `settings` as an ISO time, or "existing-install" when the install
|
|
5
|
+
* was already in use.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type Database from "better-sqlite3";
|
|
9
|
+
import { db } from "./db";
|
|
10
|
+
|
|
11
|
+
export const WELCOME_KEY = "welcome.completed_at";
|
|
12
|
+
export const EXISTING_INSTALL = "existing-install";
|
|
13
|
+
|
|
14
|
+
export interface WelcomeGate {
|
|
15
|
+
// The welcome should open now.
|
|
16
|
+
show: boolean;
|
|
17
|
+
// No sessions and no project of the user's own.
|
|
18
|
+
fresh: boolean;
|
|
19
|
+
completedAt: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GateInput {
|
|
23
|
+
completedAt: string | null;
|
|
24
|
+
sessionCount: number;
|
|
25
|
+
// Projects other than the built-in Uncategorized one.
|
|
26
|
+
projectCount: number;
|
|
27
|
+
demo: boolean;
|
|
28
|
+
skip: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function welcomeGate(i: GateInput): WelcomeGate {
|
|
32
|
+
const fresh = i.sessionCount === 0 && i.projectCount === 0;
|
|
33
|
+
return {
|
|
34
|
+
show: !i.completedAt && fresh && !i.demo && !i.skip,
|
|
35
|
+
fresh,
|
|
36
|
+
completedAt: i.completedAt,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function readCompletedAt(d: Database.Database = db): string | null {
|
|
41
|
+
const row = d
|
|
42
|
+
.prepare(`SELECT value FROM settings WHERE key = ?`)
|
|
43
|
+
.get(WELCOME_KEY) as { value: string } | undefined;
|
|
44
|
+
return row?.value ?? null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Keeps the first stamp unless `overwrite`.
|
|
48
|
+
export function completeWelcome(
|
|
49
|
+
value: string = new Date().toISOString(),
|
|
50
|
+
d: Database.Database = db,
|
|
51
|
+
overwrite = false
|
|
52
|
+
): void {
|
|
53
|
+
d.prepare(
|
|
54
|
+
overwrite
|
|
55
|
+
? `INSERT INTO settings (key, value) VALUES (?, ?)
|
|
56
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
57
|
+
: `INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`
|
|
58
|
+
).run(WELCOME_KEY, value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function counts(d: Database.Database = db) {
|
|
62
|
+
const n = (sql: string) => (d.prepare(sql).get() as { n: number }).n;
|
|
63
|
+
return {
|
|
64
|
+
sessionCount: n(`SELECT COUNT(*) AS n FROM sessions`),
|
|
65
|
+
projectCount: n(
|
|
66
|
+
`SELECT COUNT(*) AS n FROM projects WHERE is_uncategorized = 0`
|
|
67
|
+
),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The gate for this server. An install found in use is stamped as such. */
|
|
72
|
+
export function readWelcomeGate(
|
|
73
|
+
demo: boolean,
|
|
74
|
+
d: Database.Database = db
|
|
75
|
+
): WelcomeGate {
|
|
76
|
+
const c = counts(d);
|
|
77
|
+
let completedAt = readCompletedAt(d);
|
|
78
|
+
if (!completedAt && (c.sessionCount > 0 || c.projectCount > 0)) {
|
|
79
|
+
completeWelcome(EXISTING_INSTALL, d);
|
|
80
|
+
completedAt = readCompletedAt(d);
|
|
81
|
+
}
|
|
82
|
+
return welcomeGate({
|
|
83
|
+
completedAt,
|
|
84
|
+
...c,
|
|
85
|
+
demo,
|
|
86
|
+
skip: !!process.env.AGENTOS_SKIP_WELCOME,
|
|
87
|
+
});
|
|
88
|
+
}
|
package/package.json
CHANGED
package/stores/addProject.ts
CHANGED
|
@@ -13,14 +13,32 @@ export const addProjectUi = proxy<{
|
|
|
13
13
|
} | null;
|
|
14
14
|
}>({ open: null });
|
|
15
15
|
|
|
16
|
+
// Who asked for the dialog and wants the new project instead of the draft
|
|
17
|
+
// that opens in it. A function, so it stays out of the proxy.
|
|
18
|
+
let onProjectAdded: ((projectId: string) => void) | null = null;
|
|
19
|
+
|
|
16
20
|
export const addProjectActions = {
|
|
17
|
-
show: (
|
|
21
|
+
show: (
|
|
22
|
+
kind: AddProjectKind,
|
|
23
|
+
hostId: string,
|
|
24
|
+
onDone?: (projectId: string) => void
|
|
25
|
+
) => {
|
|
26
|
+
onProjectAdded = onDone ?? null;
|
|
18
27
|
addProjectUi.open = { kind, hostId, workspaceId: sidebarUi.workspaceId };
|
|
19
28
|
},
|
|
29
|
+
/** Called once a project is made; false if nobody took it over. */
|
|
30
|
+
finish: (projectId: string): boolean => {
|
|
31
|
+
const handler = onProjectAdded;
|
|
32
|
+
addProjectActions.close();
|
|
33
|
+
if (!handler) return false;
|
|
34
|
+
handler(projectId);
|
|
35
|
+
return true;
|
|
36
|
+
},
|
|
20
37
|
setWorkspace: (workspaceId: string | null) => {
|
|
21
38
|
if (addProjectUi.open) addProjectUi.open.workspaceId = workspaceId;
|
|
22
39
|
},
|
|
23
40
|
close: () => {
|
|
41
|
+
onProjectAdded = null;
|
|
24
42
|
addProjectUi.open = null;
|
|
25
43
|
},
|
|
26
44
|
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { proxy } from "valtio";
|
|
2
|
+
import type { WelcomeStep } from "@/lib/welcome-url";
|
|
3
|
+
|
|
4
|
+
// The first-run welcome: whether it is on show, the step, and the project
|
|
5
|
+
// the user made (or picked) to start in.
|
|
6
|
+
export const welcomeUi = proxy<{
|
|
7
|
+
open: boolean;
|
|
8
|
+
step: WelcomeStep;
|
|
9
|
+
projectId: string | null;
|
|
10
|
+
}>({ open: false, step: "agents", projectId: null });
|
|
11
|
+
|
|
12
|
+
export const welcomeUiActions = {
|
|
13
|
+
open: (step: WelcomeStep = "agents") => {
|
|
14
|
+
welcomeUi.step = step;
|
|
15
|
+
welcomeUi.open = true;
|
|
16
|
+
},
|
|
17
|
+
go: (step: WelcomeStep) => {
|
|
18
|
+
welcomeUi.step = step;
|
|
19
|
+
},
|
|
20
|
+
setProject: (projectId: string | null) => {
|
|
21
|
+
welcomeUi.projectId = projectId;
|
|
22
|
+
},
|
|
23
|
+
close: () => {
|
|
24
|
+
welcomeUi.open = false;
|
|
25
|
+
},
|
|
26
|
+
};
|