Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import { useLayoutEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { PageHeaderContext } from "./page-header-context";
|
||||
import { resolvePageTitle } from "@/lib/resolve-page-title";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
export function PageHeaderProvider({
|
||||
children,
|
||||
pluginTabs,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
pluginTabs: { path: string; label: string }[];
|
||||
}) {
|
||||
const { pathname } = useLocation();
|
||||
const { t } = useI18n();
|
||||
const [titleOverride, setTitleOverride] = useState<string | null>(null);
|
||||
const [afterTitle, setAfterTitle] = useState<ReactNode>(null);
|
||||
const [end, setEnd] = useState<ReactNode>(null);
|
||||
|
||||
// Clear any per-page title / toolbar slots when the path changes. Child routes
|
||||
// re-fill these on mount via usePageHeader.
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useLayoutEffect(() => {
|
||||
setTitleOverride(null);
|
||||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
}, [pathname]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
const defaultTitle = useMemo(
|
||||
() => resolvePageTitle(pathname, t, pluginTabs),
|
||||
[pathname, t, pluginTabs],
|
||||
);
|
||||
const displayTitle = titleOverride ?? defaultTitle;
|
||||
|
||||
const isChatRoute = pathname === "/chat" || pathname === "/chat/";
|
||||
/** Env jump-nav is wide — stack below title on small screens so KEYS stays readable. */
|
||||
const isEnvRoute =
|
||||
pathname === "/env" || pathname.startsWith("/env/");
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
setAfterTitle,
|
||||
setEnd,
|
||||
setTitle: setTitleOverride,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageHeaderContext.Provider value={value}>
|
||||
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<header
|
||||
className={cn(
|
||||
"z-1 w-full shrink-0",
|
||||
"box-border border-b border-current/20",
|
||||
"bg-background-base",
|
||||
// Mobile stacks title + toolbar — fixed h-14 clips content; desktop stays one row.
|
||||
"min-h-0 overflow-x-hidden overflow-y-visible py-3 sm:h-14 sm:min-h-[3.5rem] sm:overflow-hidden sm:py-0",
|
||||
)}
|
||||
role="banner"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full min-w-0 flex-1 gap-3 px-3 sm:h-full sm:gap-3 sm:px-6",
|
||||
isChatRoute
|
||||
? "flex-row items-center"
|
||||
: "flex-col justify-center sm:flex-row sm:items-center",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 gap-2 sm:gap-3",
|
||||
afterTitle && isEnvRoute
|
||||
? "flex-col items-start sm:flex-row sm:items-center"
|
||||
: afterTitle
|
||||
? "flex-row flex-wrap items-center"
|
||||
: "flex-row items-center",
|
||||
)}
|
||||
>
|
||||
<h1
|
||||
className={cn(
|
||||
"font-expanded min-w-0 text-sm font-bold tracking-[0.08em] text-midground",
|
||||
afterTitle && isEnvRoute
|
||||
? "max-w-full sm:min-w-0 sm:shrink sm:truncate"
|
||||
: afterTitle
|
||||
? "shrink truncate"
|
||||
: "truncate",
|
||||
)}
|
||||
>
|
||||
{displayTitle}
|
||||
</h1>
|
||||
{afterTitle ? (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 scrollbar-none",
|
||||
isEnvRoute
|
||||
? "w-full overflow-x-auto sm:flex-1 sm:overflow-x-auto"
|
||||
: "shrink-0 overflow-visible",
|
||||
)}
|
||||
>
|
||||
{afterTitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{end ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 sm:max-w-md sm:flex-1",
|
||||
isChatRoute
|
||||
? "w-auto shrink-0 justify-end"
|
||||
: "w-full justify-start sm:justify-end",
|
||||
)}
|
||||
>
|
||||
{end}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main
|
||||
className={cn(
|
||||
"min-h-0 w-full min-w-0 flex-1 flex flex-col",
|
||||
// Bottom inset for scrolled pages lives on the route outlet wrapper in
|
||||
// `App.tsx` (`w-full min-w-0`) so it pads scrollable content, not flex chrome.
|
||||
isChatRoute
|
||||
? "overflow-hidden"
|
||||
: "overflow-y-auto overflow-x-hidden [scrollbar-gutter:stable]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</PageHeaderContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useLocation, useSearchParams } from "react-router";
|
||||
import { api, setManagementProfile } from "@/lib/api";
|
||||
import { ProfileContext } from "@/contexts/profile-context";
|
||||
|
||||
/**
|
||||
* Machine-level management-profile scope.
|
||||
*
|
||||
* One switcher (rendered in the sidebar) decides which profile every
|
||||
* management page reads/writes. React STATE is the source of truth; the
|
||||
* URL (`?profile=<name>`) is a synchronized projection of it so deep links
|
||||
* land scoped and refresh survives. The selection is mirrored into the api
|
||||
* module so `fetchJSON` transparently appends it to the profile-scoped
|
||||
* endpoint families. "" = the dashboard's own profile.
|
||||
*
|
||||
* Why state-first instead of URL-first: sidebar nav links are bare paths
|
||||
* (`/config`, `/skills`). A URL-derived scope would silently reset to the
|
||||
* dashboard's own profile on every nav click — the switcher would LOOK
|
||||
* global while normal navigation dropped the write target. With state as
|
||||
* truth, the effect below re-asserts `?profile=` onto the new location
|
||||
* after each navigation, so the scope survives nav and stays deep-linkable.
|
||||
*
|
||||
* This exists because "Set as active" on the Profiles page historically only
|
||||
* flipped the sticky active_profile file (future CLI/gateway runs). The
|
||||
* switcher is the dashboard's write-target selector for Chat and management
|
||||
* pages. We now sync the switcher when the sticky active profile differs from
|
||||
* the dashboard process on load, and ProfilesPage updates the switcher when
|
||||
* you click "Set as active".
|
||||
*/
|
||||
export function ProfileProvider({ children }: { children: ReactNode }) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { pathname } = useLocation();
|
||||
const [profiles, setProfiles] = useState<string[]>([]);
|
||||
const [currentProfile, setCurrentProfile] = useState("default");
|
||||
|
||||
// Initial value comes from the URL (deep link / refresh / unified-launch
|
||||
// preselect); afterwards state leads and the URL follows.
|
||||
const [profile, setProfileState] = useState(
|
||||
() => searchParams.get("profile") ?? "",
|
||||
);
|
||||
|
||||
// Mirror into the api module synchronously on every render where it
|
||||
// changed, so fetches fired by child effects in the same commit see it.
|
||||
setManagementProfile(profile);
|
||||
|
||||
// A profile param arriving via in-app navigation (e.g. the Profiles
|
||||
// page's "Manage skills & tools" linking to /skills?profile=X) must win
|
||||
// over current state — it's an explicit scope request.
|
||||
const urlProfile = searchParams.get("profile");
|
||||
useEffect(() => {
|
||||
if (urlProfile !== null && urlProfile !== profile) {
|
||||
setManagementProfile(urlProfile);
|
||||
setProfileState(urlProfile);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [urlProfile]);
|
||||
|
||||
// Re-assert ?profile= after navigations that dropped it (bare nav links).
|
||||
// Runs on every pathname/profile change; no-ops when already in sync.
|
||||
useEffect(() => {
|
||||
const inUrl = searchParams.get("profile") ?? "";
|
||||
if ((profile || "") === inUrl) return;
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (profile) next.set("profile", profile);
|
||||
else next.delete("profile");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname, profile]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const urlProfile = searchParams.get("profile");
|
||||
|
||||
Promise.all([api.getProfiles(), api.getActiveProfile()])
|
||||
.then(([profilesRes, info]) => {
|
||||
if (cancelled) return;
|
||||
|
||||
setProfiles(profilesRes.profiles.map((p) => p.name));
|
||||
|
||||
const current = info.current || "default";
|
||||
const active = info.active || "default";
|
||||
setCurrentProfile(current);
|
||||
|
||||
// Deep links (?profile=) win. Otherwise align the switcher with the
|
||||
// sticky active profile so Chat and management pages match what the
|
||||
// Profiles page shows as "active" (machine dashboard runs as
|
||||
// `current`, usually default).
|
||||
if (urlProfile === null && active !== current) {
|
||||
setManagementProfile(active);
|
||||
setProfileState(active);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const setProfile = useCallback(
|
||||
(name: string) => {
|
||||
setManagementProfile(name);
|
||||
setProfileState(name);
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (name) next.set("profile", name);
|
||||
else next.delete("profile");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ profile, currentProfile, profiles, setProfile }),
|
||||
[profile, currentProfile, profiles, setProfile],
|
||||
);
|
||||
|
||||
return (
|
||||
<ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { ActionStatusResponse } from "@/lib/api";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { useI18n } from "@/i18n";
|
||||
import {
|
||||
SystemActionsContext,
|
||||
type SystemAction,
|
||||
} from "./system-actions-context";
|
||||
|
||||
const ACTION_NAMES: Record<SystemAction, string> = {
|
||||
restart: "gateway-restart",
|
||||
update: "hermes-update",
|
||||
};
|
||||
|
||||
export function SystemActionsProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [pendingAction, setPendingAction] = useState<SystemAction | null>(null);
|
||||
const [activeAction, setActiveAction] = useState<SystemAction | null>(null);
|
||||
const [actionStatus, setActionStatus] = useState<ActionStatusResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
const timer = setTimeout(() => setToast(null), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeAction) return;
|
||||
const name = ACTION_NAMES[activeAction];
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const resp = await api.getActionStatus(name);
|
||||
if (cancelled) return;
|
||||
setActionStatus(resp);
|
||||
if (!resp.running) {
|
||||
const ok = resp.exit_code === 0;
|
||||
setToast({
|
||||
type: ok ? "success" : "error",
|
||||
message: ok
|
||||
? t.status.actionFinished
|
||||
: `${t.status.actionFailed} (exit ${resp.exit_code ?? "?"})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// transient fetch error; keep polling
|
||||
}
|
||||
if (!cancelled) setTimeout(poll, 1500);
|
||||
};
|
||||
|
||||
poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeAction, t.status.actionFinished, t.status.actionFailed]);
|
||||
|
||||
const runAction = useCallback(
|
||||
async (action: SystemAction) => {
|
||||
setPendingAction(action);
|
||||
setActionStatus(null);
|
||||
try {
|
||||
if (action === "restart") {
|
||||
await api.restartGateway();
|
||||
setActiveAction(action);
|
||||
} else {
|
||||
const resp = await api.updateHermes();
|
||||
// Some installs cannot apply updates from inside the dashboard. The
|
||||
// endpoint returns a structured {ok:false, message, update_command}
|
||||
// envelope instead of spawning the action; surface that guidance
|
||||
// rather than polling a synthetic failed action.
|
||||
if (!resp.ok) {
|
||||
const cmd = resp.update_command ? ` ${resp.update_command}` : "";
|
||||
setToast({
|
||||
type: "success",
|
||||
message:
|
||||
(resp.message ??
|
||||
"Updates don't apply from this dashboard.") +
|
||||
cmd,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setActiveAction(action);
|
||||
}
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
setToast({
|
||||
type: "error",
|
||||
message: `${t.status.actionFailed}: ${detail}`,
|
||||
});
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
},
|
||||
[t.status.actionFailed],
|
||||
);
|
||||
|
||||
const dismissLog = useCallback(() => {
|
||||
setActiveAction(null);
|
||||
setActionStatus(null);
|
||||
}, []);
|
||||
|
||||
const isRunning = activeAction !== null && actionStatus?.running !== false;
|
||||
const isBusy = pendingAction !== null || isRunning;
|
||||
|
||||
return (
|
||||
<SystemActionsContext.Provider
|
||||
value={{
|
||||
actionStatus,
|
||||
activeAction,
|
||||
dismissLog,
|
||||
isBusy,
|
||||
isRunning,
|
||||
pendingAction,
|
||||
runAction,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<Toast toast={toast} />
|
||||
</SystemActionsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
message: string;
|
||||
type: "success" | "error";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createContext } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface PageHeaderContextValue {
|
||||
setAfterTitle: (node: ReactNode) => void;
|
||||
setEnd: (node: ReactNode) => void;
|
||||
setTitle: (title: string | null) => void;
|
||||
}
|
||||
|
||||
export const PageHeaderContext = createContext<PageHeaderContextValue | null>(
|
||||
null,
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export interface ProfileContextValue {
|
||||
/** Profile every management surface reads/writes ("" = the dashboard
|
||||
* process's own profile). */
|
||||
profile: string;
|
||||
/** The profile the dashboard process itself runs under. */
|
||||
currentProfile: string;
|
||||
/** Known profile names (includes "default"). */
|
||||
profiles: string[];
|
||||
setProfile: (name: string) => void;
|
||||
}
|
||||
|
||||
export const ProfileContext = createContext<ProfileContextValue>({
|
||||
profile: "",
|
||||
currentProfile: "default",
|
||||
profiles: [],
|
||||
setProfile: () => {},
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createContext } from "react";
|
||||
import type { ActionStatusResponse } from "@/lib/api";
|
||||
|
||||
export const SystemActionsContext = createContext<SystemActionsState | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export type SystemAction = "restart" | "update";
|
||||
|
||||
export interface SystemActionsState {
|
||||
actionStatus: ActionStatusResponse | null;
|
||||
activeAction: SystemAction | null;
|
||||
dismissLog: () => void;
|
||||
isBusy: boolean;
|
||||
isRunning: boolean;
|
||||
pendingAction: SystemAction | null;
|
||||
runAction: (action: SystemAction) => Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
import { PageHeaderContext, type PageHeaderContextValue } from "./page-header-context";
|
||||
|
||||
export function usePageHeader(): PageHeaderContextValue {
|
||||
const ctx = useContext(PageHeaderContext);
|
||||
if (!ctx) {
|
||||
throw new Error("usePageHeader must be used within a PageHeaderProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useContext } from "react";
|
||||
import { ProfileContext } from "@/contexts/profile-context";
|
||||
|
||||
export function useProfileScope() {
|
||||
return useContext(ProfileContext);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useContext } from "react";
|
||||
import {
|
||||
SystemActionsContext,
|
||||
type SystemActionsState,
|
||||
} from "./system-actions-context";
|
||||
|
||||
export function useSystemActions(): SystemActionsState {
|
||||
const ctx = useContext(SystemActionsContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useSystemActions must be used within a SystemActionsProvider",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user