Skip to content
Closed
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
103 changes: 52 additions & 51 deletions crates/ironclaw_webui_v2_static/static/dist/app.js

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { SidebarTraceCredits } from "./sidebar-trace-credits.js";

export function Sidebar({
threadsState,
activeThreadId = threadsState?.activeThreadId,
theme,
toggleTheme,
profile,
Expand Down Expand Up @@ -45,7 +46,7 @@ export function Sidebar({
<div className="mt-3 flex min-h-0 flex-1 flex-col">
<${SidebarThreads}
threads=${threadsState.threads}
activeThreadId=${threadsState.activeThreadId}
activeThreadId=${activeThreadId}
rebornProjectsEnabled=${rebornProjectsEnabled}
onSelect=${onSelectThread}
onDelete=${onDeleteThread}
Expand Down
101 changes: 101 additions & 0 deletions crates/ironclaw_webui_v2_static/static/js/components/sidebar.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";

function sidebarSourceForTest() {
const source = readFileSync(new URL("./sidebar.js", import.meta.url), "utf8");
const lines = [];
let skippingImport = false;
for (const line of source.split("\n")) {
if (!skippingImport && line.startsWith("import ")) {
skippingImport = !line.trimEnd().endsWith(";");
continue;
}
if (skippingImport) {
skippingImport = !line.trimEnd().endsWith(";");
continue;
}
lines.push(line.replace("export function Sidebar", "function Sidebar"));
}
return `${lines.join("\n")}\nglobalThis.__testExports = { Sidebar };`;
}

function html(strings, ...values) {
return { strings: Array.from(strings), values };
}

function findComponent(node, component) {
if (Array.isArray(node)) {
for (const child of node) {
const found = findComponent(child, component);
if (found) return found;
}
return null;
}
if (!node || typeof node !== "object") return null;
if (Array.isArray(node.values) && node.values.includes(component)) return node;
if (!Array.isArray(node.values)) return null;
for (const value of node.values) {
const found = findComponent(value, component);
if (found) return found;
}
return null;
}

function componentProps(node, component) {
const props = {};
const start = node.values.indexOf(component);
for (let index = start + 1; index < node.values.length; index += 1) {
const name = node.strings[index]?.match(/([A-Za-z][A-Za-z0-9]*)=\s*$/)?.[1];
if (name) props[name] = node.values[index];
}
return props;
}

function renderSidebar(props = {}) {
const context = {
Link() {},
SidebarFooter() {},
SidebarNav() {},
SidebarThreads() {},
SidebarTraceCredits() {},
globalThis: {},
html,
};
const threadsState = {
activeThreadId: "thread-1",
isCreating: false,
threads: [{ id: "thread-1", title: "Chat one" }],
};

vm.runInNewContext(sidebarSourceForTest(), context);
const tree = context.globalThis.__testExports.Sidebar({
isAdmin: false,
onClose: () => {},
onDeleteThread: () => {},
onNewChat: () => {},
onSelectThread: () => {},
onSignOut: () => {},
profile: null,
theme: "light",
threadsState,
toggleTheme: () => {},
...props,
});
const threads = findComponent(tree, context.SidebarThreads);
assert.ok(threads, "Sidebar should render SidebarThreads");
return componentProps(threads, context.SidebarThreads);
}

test("Sidebar lets callers clear the active thread highlight", () => {
const props = renderSidebar({ activeThreadId: null });

assert.equal(props.activeThreadId, null);
});

test("Sidebar falls back to the thread state's active id", () => {
const props = renderSidebar();

assert.equal(props.activeThreadId, "thread-1");
});
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ export function GatewayLayout({
});
const onboardingExempt =
location.pathname === "/welcome" || location.pathname.startsWith("/settings");
const isChatRoute =
location.pathname === "/chat" || location.pathname.startsWith("/chat/");
const sidebarActiveThreadId = isChatRoute ? threadsState?.activeThreadId : null;

const [paletteOpen, setPaletteOpen] = React.useState(false);
React.useEffect(() => {
Expand Down Expand Up @@ -110,6 +113,7 @@ export function GatewayLayout({
>
<${Sidebar}
threadsState=${threadsState}
activeThreadId=${sidebarActiveThreadId}
theme=${theme}
toggleTheme=${toggleTheme}
profile=${profile}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";

function gatewayLayoutSourceForTest() {
const source = readFileSync(new URL("./gateway-layout.js", import.meta.url), "utf8");
const lines = [];
let skippingImport = false;
for (const line of source.split("\n")) {
if (!skippingImport && line.startsWith("import ")) {
skippingImport = !line.trimEnd().endsWith(";");
continue;
}
if (skippingImport) {
skippingImport = !line.trimEnd().endsWith(";");
continue;
}
lines.push(line.replace("export function GatewayLayout", "function GatewayLayout"));
}
return `${lines.join("\n")}\nglobalThis.__testExports = { GatewayLayout };`;
}

function html(strings, ...values) {
return { strings: Array.from(strings), values };
}

function findComponent(node, component) {
if (Array.isArray(node)) {
for (const child of node) {
const found = findComponent(child, component);
if (found) return found;
}
return null;
}
if (!node || typeof node !== "object") return null;
if (Array.isArray(node.values) && node.values.includes(component)) return node;
if (!Array.isArray(node.values)) return null;
for (const value of node.values) {
const found = findComponent(value, component);
if (found) return found;
}
return null;
}

function componentProps(node, component) {
const props = {};
const start = node.values.indexOf(component);
for (let index = start + 1; index < node.values.length; index += 1) {
const name = node.strings[index]?.match(/([A-Za-z][A-Za-z0-9]*)=\s*$/)?.[1];
if (name) props[name] = node.values[index];
}
return props;
}

function renderGatewayLayout(pathname) {
const threadsState = {
threads: [{ id: "thread-1", title: "Chat one" }],
activeThreadId: "thread-1",
isCreating: false,
setActiveThreadId: () => {},
deleteThread: async () => {},
};
const context = {
CommandPalette() {},
Navigate() {},
Outlet() {},
PageHeader() {},
React: {
useCallback: (fn) => fn,
useEffect: () => {},
useState: (initial) => [typeof initial === "function" ? initial() : initial, () => {}],
},
Sidebar() {},
ToastViewport() {},
cn: (...classes) => classes.filter(Boolean).join(" "),
deleteThreadErrorMessage: () => "delete failed",
globalThis: {},
html,
shouldRouteToOnboarding: () => false,
toast: () => {},
useGatewayStatus: () => ({ data: { ok: true }, error: null }),
useInterfaceTheme: () => ({ theme: "light", toggleTheme: () => {} }),
useLlmProviders: () => ({
hasActiveProvider: true,
isError: false,
isLoading: false,
}),
useLocation: () => ({ pathname }),
useNavigate: () => () => {},
useSidebar: () => ({
close: () => {},
newChat: () => {},
open: true,
selectThread: () => {},
toggle: () => {},
}),
useT: () => (key) => key,
useThreads: () => threadsState,
};

vm.runInNewContext(gatewayLayoutSourceForTest(), context);
const tree = context.globalThis.__testExports.GatewayLayout({
isAdmin: false,
onSignOut: () => {},
profile: null,
token: "token",
});
const sidebar = findComponent(tree, context.Sidebar);
assert.ok(sidebar, "GatewayLayout should render Sidebar");
return componentProps(sidebar, context.Sidebar);
}

test("GatewayLayout clears the sidebar active thread outside chat routes", () => {
const props = renderGatewayLayout("/automations");

assert.equal(props.activeThreadId, null);
});

test("GatewayLayout keeps the sidebar active thread on chat routes", () => {
const props = renderGatewayLayout("/chat/thread-1");

assert.equal(props.activeThreadId, "thread-1");
});
Loading