Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ forward to v0.9.0 (tracked in [#100](https://github.com/OmniBioAI/omnibioai-stud

### v0.7.0 ✅
- 🔐 **Unified license key system** (`OMNI-XXXX-XXXX-XXXX-XXXX`) — one key works for web + desktop, auto-creates user on first validation, same JWT as OAuth login
- 🌐 **webstudio.omnibioai.org fully working** — license key login enforced, all workbench pages loading correctly, service worker fixed, nginx routes fixed (`/license/`, `/roles/`), Control Center JWT cookie fallback
- 🌐 **webstudio.omnibioai.org fully working** — license key login enforced, all Studio pages loading correctly, service worker fixed, nginx routes fixed (`/license/`, `/roles/`), Control Center JWT cookie fallback
- 👥 **Team expansion** — Dr. Rajnish Kumar (Scientific Consultant, SR University), Praveen C.V. Raghavulu (Scientific Advisor, KUMC); About page restructured as company team page
- 🛠 **12,110 bioinformatics tools** (up from 11,577) — 100+ new HTTP API tools added, all tools validated (100% clean), 0 duplicates, 0 ToolSpec errors
- 🤖 **Tool selection AI improved** — GPU enabled for Ollama (57x faster: 120s → 2.1s), accuracy 0% → 60%, Recall@K 60% → 85%
Expand Down
29 changes: 22 additions & 7 deletions src/ui/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import Launch from "./pages/Launch";
import Services from "./pages/Services";
import Logs from "./pages/Logs";
import Settings from "./pages/Settings";
import Workbench from "./pages/Workbench";
import Studio from "./pages/Studio";
import Jobs from "./pages/Jobs";
import ServiceViewer from "./pages/ServiceViewer";
import Videos from "./pages/Videos";
Expand All @@ -37,7 +37,7 @@ const BASE_NAV = [
{ name:"Services", idx:5 },
{ name:"IDE Services", idx:10 },
{ name:"Logs", idx:6 },
{ name:"Workbench", idx:7 },
{ name:"Studio", idx:7 },
{ name:"Jobs", idx:9 },
{ name:"Billing", idx:12 },
]},
Expand All @@ -63,9 +63,12 @@ const WIZARD_MAX = 4;

const PAGE_NAMES = [
"mode","llm","cloud","hpc","launch",
"services","logs","workbench","settings","jobs","ide-services","roles","billing"
"services","logs","studio","settings","jobs","ide-services","roles","billing"
];

const STUDIO_PATH = "/studio";
const LEGACY_PORTAL_PATH = "/workbench";

// Same-origin relative paths only -- must start with exactly one "/",
// never "//" (browsers treat a leading "//" as protocol-relative, an open
// redirect to any host), and must not resolve to a different origin.
Expand All @@ -86,7 +89,7 @@ function getSafeReturnTo() {
}

export default function App() {
const [step, setStep] = useState(0);
const [step, setStep] = useState(7);
const [systemStatus, setSystemStatus] = useState("idle");
const [ready, setReady] = useState(false);
const [config, setConfig] = useState({
Expand Down Expand Up @@ -137,6 +140,18 @@ export default function App() {
init();
}, []);

// The shell historically loaded at `/`. Keep that entry point working while
// giving the authenticated portal a stable `/studio` URL. `/workbench` is
// retained as a bookmark-safe alias for the portal; the real Workbench
// service remains under `/_svc/workbench/` and is never claimed here.
useEffect(() => {
const url = new URL(window.location.href);
if (url.pathname === "/" || url.pathname === LEGACY_PORTAL_PATH) {
url.pathname = STUDIO_PATH;
window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
}
}, []);

// ─── Listen for navigate events (from Workbench page) ──
useEffect(() => {
const handler = (e) => setStep(e.detail);
Expand Down Expand Up @@ -226,7 +241,7 @@ export default function App() {
<Launch config={config} onStatusChange={setSystemStatus} />,
<Services config={config} currentUser={currentUser} />,
<Logs />,
<Workbench />,
<Studio />,
<Settings config={config} setConfig={setConfig} currentUser={currentUser} />,
<Jobs />,
<IdeServices currentUser={currentUser} />,
Expand All @@ -239,7 +254,7 @@ export default function App() {

function handleStudioClick() {
setService(null);
setStep(7); // return to Workbench
setStep(7); // return to Studio
}

// Electron webview bypasses Vite proxy for relative URLs; prefix /_svc/*
Expand Down Expand Up @@ -335,7 +350,7 @@ export default function App() {
<span
onClick={handleStudioClick}
style={{ cursor:"pointer" }}
title="Back to Workbench"
title="Back to Studio"
>studio</span>
{" / "}
<span style={{ color:"var(--text)" }}>{currentName}</span>
Expand Down
2 changes: 1 addition & 1 deletion src/ui/components/GrafanaViewer/GrafanaViewer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export function GrafanaViewer({ onBack, label }) {
fontWeight: 600, cursor: "pointer",
}}
>
← Back to Workbench
← Back to Studio
</button>
<span style={{
fontSize: "var(--font-size-xs)", fontFamily: "var(--mono)",
Expand Down
2 changes: 1 addition & 1 deletion src/ui/pages/ServiceViewer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export default function ServiceViewer({ url, label, onBack }) {
fontWeight: 600, cursor: "pointer",
}}
>
← Back to Workbench
← Back to Studio
</button>
<span style={{
fontSize: "var(--font-size-xs)", fontFamily: "var(--mono)",
Expand Down
6 changes: 3 additions & 3 deletions src/ui/pages/Workbench.jsx → src/ui/pages/Studio.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ function buildCategories(BASE) {
];
}

export default function Workbench() {
export default function Studio() {
const [host, setHost] = useState(getInitialHost);
const [online, setOnline] = useState(false);
const [checking, setChecking] = useState(true);
Expand Down Expand Up @@ -275,10 +275,10 @@ export default function Workbench() {
<div className="workbench-header">
<div>
<div style={{ fontSize:20, fontWeight:700, color:"#fff", letterSpacing:"-0.01em", marginBottom:3 }}>
Workbench
OmniBioAI Studio
</div>
<div style={{ fontSize:'var(--font-size-sm)', color:"var(--color-text-muted)", fontFamily:"var(--mono)" }}>
OmniBioAI bioinformatics platform — quick access to key modules
Unified access to OmniBioAI platform services, workflows, AI, and security
</div>
</div>

Expand Down
2 changes: 1 addition & 1 deletion src/ui/pages/Videos.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ function Toolbar({ onBack, count }) {
fontSize:"var(--font-size-xs)", fontWeight:600, cursor:"pointer",
}}
>
← Back to Workbench
← Back to Studio
</button>

<span style={{ fontSize:"var(--font-size-xs)", fontFamily:"var(--mono)", color:"var(--color-text-muted)" }}>
Expand Down
51 changes: 36 additions & 15 deletions tests/ui/app-shell.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ vi.mock("../../src/ui/pages/HPC", () => ({ default: () => <div>HPC page</div> })
vi.mock("../../src/ui/pages/Launch", () => ({ default: ({ onStatusChange }) => <div>Launch page<button onClick={() => onStatusChange("running")}>go-running</button><button onClick={() => onStatusChange("error")}>go-error</button><button onClick={() => onStatusChange("starting")}>go-starting</button></div> }));
vi.mock("../../src/ui/pages/Services", () => ({ default: () => <div>Services page</div> }));
vi.mock("../../src/ui/pages/Logs", () => ({ default: () => <div>Logs page</div> }));
vi.mock("../../src/ui/pages/Workbench", () => ({ default: () => <div>Workbench page</div> }));
vi.mock("../../src/ui/pages/Studio", () => ({ default: () => <div>Studio page</div> }));
vi.mock("../../src/ui/pages/Settings", () => ({ default: () => <div>Settings page</div> }));
vi.mock("../../src/ui/pages/Jobs", () => ({ default: () => <div>Jobs page</div> }));
vi.mock("../../src/ui/pages/IdeServices", () => ({ default: () => <div>IDE page</div> }));
Expand All @@ -50,6 +50,25 @@ beforeEach(() => {
onSessionChange.mockReturnValue(vi.fn());
window.history.replaceState({}, "", "/");
});

describe("App shell — Studio landing", () => {
it("lands authenticated users on Studio and keeps Mode accessible", async () => {
getCurrentUser.mockResolvedValue(admin);
render(<App />);
await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument());
expect(screen.getByText("Studio", { selector: "div" })).toBeInTheDocument();
fireEvent.click(screen.getByText("Mode", { selector: "div" }));
await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument());
});

it("maps the legacy portal path to /studio", async () => {
window.history.replaceState({}, "", "/workbench");
getCurrentUser.mockResolvedValue(admin);
render(<App />);
await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument());
expect(window.location.pathname).toBe("/studio");
});
});
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.clearAllMocks(); delete window.api; });

const admin = { userId: 1, email: "admin@test", permissions: ["manage_roles"] };
Expand All @@ -72,24 +91,24 @@ describe("App shell — loading and first-run", () => {

window.api = { loadConfig: vi.fn().mockResolvedValue({ mode: "local", settings: { data_dir: "/d" } }) };
render(<App />);
await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument());
await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument());
});

it("stays ready and on Mode when loadConfig throws (dev mode)", async () => {
window.api = { loadConfig: vi.fn().mockRejectedValue(new Error("no ipc")) };
render(<App />);
await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument());
await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument());
});

it("becomes ready immediately with no window.api at all", async () => {
render(<App />);
await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument());
await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument());
});

it("defaults an unset saved mode to beta", async () => {
window.api = { loadConfig: vi.fn().mockResolvedValue({ settings: { data_dir: "/d" } }) };
render(<App />);
await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument());
await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument());
});
});

Expand All @@ -108,7 +127,7 @@ describe("App shell — web auth gate", () => {
isElectron.mockReturnValue(false);
getCurrentUser.mockResolvedValue(admin);
render(<App />);
await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument());
await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument());
expect(screen.getByText("Roles")).toBeInTheDocument();
});
});
Expand All @@ -117,7 +136,7 @@ describe("App shell — navigation and roles nav", () => {
it("hides the Roles nav item for a user without manage_roles, once known", async () => {
getCurrentUser.mockResolvedValue({ userId: 2, email: "u@test", permissions: [] });
render(<App />);
await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument());
await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument());
expect(screen.queryByText("Roles")).not.toBeInTheDocument();
});

Expand All @@ -132,7 +151,7 @@ describe("App shell — navigation and roles nav", () => {
it("responds to navigate and open-service window events", async () => {
getCurrentUser.mockResolvedValue(admin);
render(<App />);
await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument());
await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument());
window.dispatchEvent(new CustomEvent("navigate", { detail: 9 }));
await waitFor(() => expect(screen.getByText("Jobs page")).toBeInTheDocument());

Expand All @@ -145,13 +164,13 @@ describe("App shell — navigation and roles nav", () => {
await waitFor(() => expect(screen.getByText(/ServiceViewer:Other Service/)).toBeInTheDocument());
// breadcrumb "studio" click returns to Workbench and clears the service view
fireEvent.click(screen.getByText("studio"));
await waitFor(() => expect(screen.getByText("Workbench page")).toBeInTheDocument());
await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument());
});

it("routes videos and Grafana service opens to their dedicated viewers", async () => {
getCurrentUser.mockResolvedValue(admin);
render(<App />);
await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument());
await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument());

window.dispatchEvent(new CustomEvent("open-service", { detail: { url: "/_svc/videos", label: "Videos" } }));
await waitFor(() => expect(screen.getByText("Videos page")).toBeInTheDocument());
Expand Down Expand Up @@ -181,23 +200,25 @@ describe("App shell — navigation and roles nav", () => {
it("walks the wizard controls: dot navigation, Back/Next, and boundary disabling", async () => {
getCurrentUser.mockResolvedValue(admin);
render(<App />);
await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument());
fireEvent.click(screen.getByText("Mode", { selector: "div" }));
await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument());
expect(screen.getByText("Back")).toBeDisabled();
fireEvent.click(screen.getByText("Next →"));
await waitFor(() => expect(screen.getByText("LLM page")).toBeInTheDocument());
fireEvent.click(screen.getByText("Back"));
await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument());

fireEvent.click(screen.getByTitle("Launch"));
fireEvent.click(screen.getByText("Launch", { selector: "div" }));
await waitFor(() => expect(screen.getByText("Launch page")).toBeInTheDocument());
expect(screen.getByText("Next →")).toBeDisabled();
});

it("reflects running and error system status from the Launch page", async () => {
getCurrentUser.mockResolvedValue(admin);
render(<App />);
await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument());
fireEvent.click(screen.getByTitle("Launch"));
await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument());
fireEvent.click(screen.getByText("Launch", { selector: "div" }));
await waitFor(() => expect(screen.getByText("Launch page")).toBeInTheDocument());
fireEvent.click(screen.getByText("go-starting"));
await waitFor(() => expect(screen.getAllByText("STARTING").length).toBeGreaterThan(0));
Expand Down Expand Up @@ -249,7 +270,7 @@ describe("App shell — token refresh and return_to redirect", () => {
getRefreshToken.mockReturnValue(null);
getCurrentUser.mockResolvedValue(admin);
render(<App />);
await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument());
await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument());
expect(refresh).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -282,6 +303,6 @@ describe("App shell — token refresh and return_to redirect", () => {
render(<App />);
// jsdom doesn't implement real navigation, but the assignment itself
// must not throw and the shell should still render.
await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument());
await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument());
});
});
4 changes: 2 additions & 2 deletions tests/ui/app.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ vi.mock("../../src/ui/pages/HPC", () => ({ default: () => <div>HPC page</div> })
vi.mock("../../src/ui/pages/Launch", () => ({ default: () => <div>Launch page</div> }));
vi.mock("../../src/ui/pages/Services", () => ({ default: () => <div>Services page</div> }));
vi.mock("../../src/ui/pages/Logs", () => ({ default: () => <div>Logs page</div> }));
vi.mock("../../src/ui/pages/Workbench", () => ({ default: () => <div>Workbench page</div> }));
vi.mock("../../src/ui/pages/Studio", () => ({ default: () => <div>Studio page</div> }));
vi.mock("../../src/ui/pages/Settings", () => ({ default: () => <div>Settings page</div> }));
vi.mock("../../src/ui/pages/Jobs", () => ({ default: () => <div>Jobs page</div> }));
vi.mock("../../src/ui/pages/IdeServices", () => ({ default: () => <div>IDE page</div> }));
Expand All @@ -49,7 +49,7 @@ describe("application shell", () => {
it("renders the authorized shell and responds to application navigation events", async () => {
getCurrentUser.mockResolvedValueOnce({ userId: 1, email: "admin@test", permissions: ["manage_roles"] });
render(<App />);
await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument());
await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument());
expect(screen.getByText("Roles")).toBeInTheDocument();
window.dispatchEvent(new CustomEvent("navigate", { detail: 9 }));
await waitFor(() => expect(screen.getByText("Jobs page")).toBeInTheDocument());
Expand Down
6 changes: 3 additions & 3 deletions tests/ui/coverage_matrix.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import Logs from "../../src/ui/pages/Logs";
import Services from "../../src/ui/pages/Services";
import IdeServices from "../../src/ui/pages/IdeServices";
import Jobs from "../../src/ui/pages/Jobs";
import Workbench from "../../src/ui/pages/Workbench";
import Studio from "../../src/ui/pages/Studio";
import RoleManagement from "../../src/ui/pages/RoleManagement";
import ServiceViewer from "../../src/ui/pages/ServiceViewer";
import Videos from "../../src/ui/pages/Videos";
Expand Down Expand Up @@ -71,7 +71,7 @@ afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.clearAllMocks(); });
describe("page and component coverage matrix", () => {
it("walks the authorized production shell through every route", async () => {
render(<App />);
await waitFor(() => expect(screen.getByText("Runtime Mode")).toBeInTheDocument());
await waitFor(() => expect(screen.getByText("OmniBioAI Studio")).toBeInTheDocument());
for (let step = 0; step <= 14; step += 1) {
window.dispatchEvent(new CustomEvent("navigate", { detail: step }));
await waitFor(() => expect(document.body.textContent.length).toBeGreaterThan(20));
Expand All @@ -96,7 +96,7 @@ describe("page and component coverage matrix", () => {
[Services, { config, currentUser: user }],
[IdeServices, { currentUser: user }],
[Jobs, {}],
[Workbench, {}],
[Studio, {}],
]) {
const { unmount } = render(<Page {...props} />);
await waitFor(() => expect(document.body.textContent.length).toBeGreaterThan(0));
Expand Down
2 changes: 1 addition & 1 deletion tests/ui/grafana-viewer.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe("GrafanaViewer — web build", () => {
fireEvent.click(screen.getByText("RAG"));
expect(document.querySelector("iframe").src).toContain("omnibioai-rag");

fireEvent.click(screen.getByText("← Back to Workbench"));
fireEvent.click(screen.getByText("← Back to Studio"));
expect(onBack).toHaveBeenCalled();
});

Expand Down
2 changes: 1 addition & 1 deletion tests/ui/service-viewer.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe("ServiceViewer", () => {
it("calls onBack", () => {
const onBack = vi.fn();
render(<ServiceViewer url="/service" label="Service" onBack={onBack} />);
fireEvent.click(screen.getByText("← Back to Workbench"));
fireEvent.click(screen.getByText("← Back to Studio"));
expect(onBack).toHaveBeenCalled();
});

Expand Down
Loading
Loading