feat: load plugin frontends at runtime and remove plugin ids from the shell

This commit is contained in:
LukeGus committed 2026-09-22 20:15:52 -05:00
1 parent 49f0286a14
commit e88d5a52e3
721 files changed
+54265 -44416

No files matched your search

+25 -2
View File
@@ -10,7 +10,15 @@ import {
DialogTitle,
} from "@/components/dialog";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { relevantSteps, type OnboardingContext } from "./onboarding-steps";
import { useActionSlot } from "@/hooks/use-action-slot";
import {
relevantSteps,
type OnboardingContext,
type OnboardingStep,
} from "./onboarding-steps";
/** Plugin steps go in before this one. */
const PLUGIN_STEPS_BEFORE = "security";
export function OnboardingDialog({
open,
@@ -23,7 +31,22 @@ export function OnboardingDialog({
}) {
const { t } = useTranslation();
const ctx = useUiPreferencesContext();
const steps = useMemo(() => relevantSteps(context), [context]);
const pluginSteps = useActionSlot("onboarding.steps");
const steps = useMemo(() => {
const core = relevantSteps(context);
const extra: OnboardingStep[] = pluginSteps
.filter((contribution) => contribution.component)
.map((contribution) => ({
id: contribution.actionId,
titleKey: contribution.titleKey,
Component:
contribution.component as unknown as OnboardingStep["Component"],
}));
const at = core.findIndex((step) => step.id === PLUGIN_STEPS_BEFORE);
return at < 0
? [...core, ...extra]
: [...core.slice(0, at), ...extra, ...core.slice(at)];
}, [context, pluginSteps]);
const [index, setIndex] = useState(0);
// A reopened run always starts from the top.
+4 -14
View File
@@ -5,13 +5,10 @@ import { AppearanceStep } from "./steps/AppearanceStep";
import { FeaturesStep } from "./steps/FeaturesStep";
import { WorkflowStep } from "./steps/WorkflowStep";
import { SecurityStep } from "./steps/SecurityStep";
import { AiAssistantStep } from "./steps/AiAssistantStep";
import { DoneStep } from "./steps/DoneStep";
export interface OnboardingContext {
/** Whether an admin has enabled the AI assistant for this instance. */
aiGloballyEnabled: boolean;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface OnboardingContext {}
export interface OnboardingStepProps {
context: OnboardingContext;
@@ -27,7 +24,8 @@ export interface OnboardingStep {
/**
* Onboarding as data rather than hardcoded JSX, so steps can be added,
* reordered or made conditional without touching the dialog shell.
* reordered or made conditional without touching the dialog shell. Plugins
* add steps through the "onboarding.steps" slot; they land before security.
*/
export const ONBOARDING_STEPS: OnboardingStep[] = [
{
@@ -51,14 +49,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
titleKey: "onboarding.workflowTitle",
Component: WorkflowStep,
},
{
id: "ai",
titleKey: "onboarding.aiTitle",
Component: AiAssistantStep,
// Never shown when the admin has the assistant switched off, so users on
// an instance without it are not told about a feature they cannot use.
isRelevant: (context) => context.aiGloballyEnabled,
},
{
id: "security",
titleKey: "onboarding.securityTitle",
@@ -1,97 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check } from "lucide-react";
import { saveUserPreferences } from "@/main-axios";
import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
/**
* Asks once, plainly.
*
* "Leave it off" is preselected and applied on mount, so someone who clicks
* straight through ends up without the assistant rather than with it. Both
* options are described in the same flat register and neither is marked
* recommended: this is a choice, not a pitch.
*/
export function AiAssistantStep() {
const { t } = useTranslation();
const [enabled, setEnabled] = useState(false);
function apply(next: boolean) {
setEnabled(next);
const hidden = readHiddenRailTabs();
if (next) hidden.delete("ai");
else hidden.add("ai");
const serialized = JSON.stringify([...hidden]);
localStorage.setItem("hiddenRailTabs", serialized);
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
saveUserPreferences({
aiAssistantEnabled: next,
hiddenRailTabs: serialized,
}).catch(() => {
// The local choice still applies if the write fails.
});
}
// Skipping the step entirely still has to mean off, not unset.
useEffect(() => {
apply(false);
}, []);
const options = [
{
value: false,
title: t("onboarding.aiSkipTitle"),
description: t("onboarding.aiSkipDesc"),
},
{
value: true,
title: t("onboarding.aiEnableTitle"),
description: t("onboarding.aiEnableDesc"),
},
];
return (
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">{t("onboarding.aiIntro")}</p>
<div className="flex flex-col gap-2">
{options.map((option) => (
<button
key={String(option.value)}
type="button"
onClick={() => apply(option.value)}
className={`flex items-start gap-2.5 border p-2.5 text-left ${
enabled === option.value
? "border-accent-brand bg-accent-brand/10"
: "border-border bg-card"
}`}
>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs font-medium">{option.title}</span>
<span className="text-[10px] leading-snug text-muted-foreground">
{option.description}
</span>
</div>
{/*
Always rendered and only made visible when selected, so picking an
option never reflows the text beside it.
*/}
<Check
size={14}
className={`ml-auto mt-0.5 shrink-0 ${
enabled === option.value ? "opacity-100" : "opacity-0"
}`}
/>
</button>
))}
</div>
<p className="text-[10px] text-muted-foreground/70">
{t("onboarding.aiNote")}
</p>
</div>
);
}
+26 -22
View File
@@ -1,32 +1,36 @@
import { useTranslation } from "react-i18next";
import {
Boxes,
FolderTree,
MonitorSmartphone,
Network,
Play,
Plug,
Workflow,
type LucideIcon,
} from "lucide-react";
import { FolderTree, Play, Plug, type LucideIcon } from "lucide-react";
import { useActionSlot } from "@/hooks/use-action-slot";
/**
* A tour of the things people miss because they live behind a rail icon.
* Terminal and hosts are covered by the welcome step, so this is deliberately
* the "there is more than SSH here" list.
* the "there is more than SSH here" list. Plugins add their own entries
* through the "onboarding.features" slot.
*/
const FEATURES: { icon: LucideIcon; key: string }[] = [
{ icon: FolderTree, key: "files" },
{ icon: MonitorSmartphone, key: "desktop" },
{ icon: Plug, key: "tunnels" },
{ icon: Boxes, key: "docker" },
{ icon: Play, key: "snippets" },
{ icon: Workflow, key: "automations" },
{ icon: Network, key: "metrics" },
];
export function FeaturesStep() {
const { t } = useTranslation();
const pluginFeatures = useActionSlot("onboarding.features");
const features = [
...FEATURES.map(({ icon, key }) => ({
id: key,
icon,
title: t(`onboarding.feature_${key}`),
description: t(`onboarding.feature_${key}_desc`),
})),
...pluginFeatures.map((feature) => ({
id: feature.actionId,
icon: feature.icon as LucideIcon | undefined,
title: t(feature.titleKey),
description: feature.descriptionKey ? t(feature.descriptionKey) : "",
})),
];
return (
<div className="flex flex-col gap-3">
@@ -35,18 +39,18 @@ export function FeaturesStep() {
</p>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{FEATURES.map(({ icon: Icon, key }) => (
{features.map(({ icon: Icon, id, title, description }) => (
<div
key={key}
key={id}
className="flex items-start gap-2.5 border border-border bg-card p-2.5"
>
<Icon size={14} className="mt-0.5 shrink-0 text-accent-brand" />
{Icon && (
<Icon size={14} className="mt-0.5 shrink-0 text-accent-brand" />
)}
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{t(`onboarding.feature_${key}`)}
</span>
<span className="text-xs font-medium">{title}</span>
<span className="text-[10px] leading-snug text-muted-foreground">
{t(`onboarding.feature_${key}_desc`)}
{description}
</span>
</div>
</div>
+27 -12
View File
@@ -1,6 +1,7 @@
import { useTranslation } from "react-i18next";
import { Columns2, LayoutTemplate, PanelRight, Search } from "lucide-react";
import { Columns2, PanelRight, Search, type LucideIcon } from "lucide-react";
import { Kbd } from "@/components/kbd";
import { useActionSlot } from "@/hooks/use-action-slot";
/**
* The handful of navigation habits that make the app feel fast. Each row shows
@@ -9,6 +10,24 @@ import { Kbd } from "@/components/kbd";
*/
export function WorkflowStep() {
const { t } = useTranslation();
const pluginTips = useActionSlot("onboarding.workflow");
const tips = [
...[
{ icon: Columns2, key: "split" },
{ icon: PanelRight, key: "dock" },
].map(({ icon, key }) => ({
id: key,
icon: icon as LucideIcon | undefined,
title: t(`onboarding.workflow_${key}`),
description: t(`onboarding.workflow_${key}_desc`),
})),
...pluginTips.map((tip) => ({
id: tip.actionId,
icon: tip.icon as LucideIcon | undefined,
title: t(tip.titleKey),
description: tip.descriptionKey ? t(tip.descriptionKey) : "",
})),
];
return (
<div className="flex flex-col gap-3">
@@ -33,22 +52,18 @@ export function WorkflowStep() {
</div>
</div>
{[
{ icon: Columns2, key: "split" },
{ icon: PanelRight, key: "dock" },
{ icon: LayoutTemplate, key: "workspaces" },
].map(({ icon: Icon, key }) => (
{tips.map(({ icon: Icon, id, title, description }) => (
<div
key={key}
key={id}
className="flex items-start gap-2.5 border border-border bg-card p-2.5"
>
<Icon size={14} className="mt-0.5 shrink-0 text-accent-brand" />
{Icon && (
<Icon size={14} className="mt-0.5 shrink-0 text-accent-brand" />
)}
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{t(`onboarding.workflow_${key}`)}
</span>
<span className="text-xs font-medium">{title}</span>
<span className="text-[10px] leading-snug text-muted-foreground">
{t(`onboarding.workflow_${key}_desc`)}
{description}
</span>
</div>
</div>