Skip to content
84 changes: 55 additions & 29 deletions builds/typescript/client_web/src/api/config-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,41 +21,67 @@ export type GatewayClientConfig = {
appVersion: string;
};

const DEFAULT_LOCAL_CONFIG: GatewayClientConfig = {
mode: "local",
gatewayUrl: "/api",
billingUrl: "https://my.braindrive.ai/credits",
installMode: "unknown",
installLocation: "unknown",
appVersion: "unknown",
};

export async function getConfig(): Promise<GatewayClientConfig> {
try {
const response = await fetch("/api/config", {
headers: buildLocalOwnerHeaders(),
});
if (!response.ok) {
// Retry on failure with exponential backoff before falling through to
// the local-mode default. This endpoint determines whether BD Core
// shows its own login screen or trusts gateway-injected auth (managed
// mode); falling through to "local" prematurely on a transient network
// hiccup or a still-booting container produces a confusing login screen
// for users who are already authenticated via the gateway. After this
// retry budget is exhausted, we still default to "local" — that's the
// safe choice for genuine local installs where /api/config legitimately
// 404s. The handoff path on the gateway side also probes this endpoint
// before redirecting, so reaching this fallback at all should be rare.
const attempts = [0, 500, 1000, 2000, 4000]; // ms backoff between attempts (5 total tries)
let lastError: unknown = null;

for (let i = 0; i < attempts.length; i++) {
if (attempts[i] > 0) {
await new Promise((r) => setTimeout(r, attempts[i]));
}
try {
const response = await fetch("/api/config", {
headers: buildLocalOwnerHeaders(),
});
if (!response.ok) {
// Non-200: server reachable but unhappy. Could be 404 (genuine
// local install with no managed gateway) or 502/503 (managed
// container still booting). Retry — only fall through if we run
// out of attempts.
lastError = new Error(`HTTP ${response.status}`);
continue;
}
const payload = (await response.json()) as GatewayConfig;
return {
mode: "local",
gatewayUrl: "/api",
billingUrl: "https://my.braindrive.ai/credits",
installMode: "unknown",
installLocation: "unknown",
appVersion: "unknown"
mode: toDeploymentMode(payload.mode),
gatewayUrl: payload.gateway_url || "/api",
billingUrl: payload.billing_url ?? "https://my.braindrive.ai/credits",
installMode: toInstallMode(payload.install_mode),
installLocation: toInstallLocation(payload.install_location),
appVersion: toAppVersion(payload.app_version),
};
} catch (err) {
lastError = err;
}
}

const payload = (await response.json()) as GatewayConfig;
return {
mode: toDeploymentMode(payload.mode),
gatewayUrl: payload.gateway_url || "/api",
billingUrl: payload.billing_url ?? "https://my.braindrive.ai/credits",
installMode: toInstallMode(payload.install_mode),
installLocation: toInstallLocation(payload.install_location),
appVersion: toAppVersion(payload.app_version),
};
} catch {
return {
mode: "local",
gatewayUrl: "/api",
billingUrl: "https://my.braindrive.ai/credits",
installMode: "unknown",
installLocation: "unknown",
appVersion: "unknown"
};
// All attempts exhausted — log and fall through to local-mode default.
// For a true local install this is correct; for a managed install it
// means the gateway is genuinely down and the user can't authenticate
// anyway, so showing the login screen is acceptable degraded UX.
if (lastError) {
console.warn("[BD Core] getConfig failed after retries, defaulting to local mode:", lastError);
}
return DEFAULT_LOCAL_CONFIG;
}

function toDeploymentMode(value: unknown): "local" | "managed" {
Expand Down
32 changes: 30 additions & 2 deletions builds/typescript/client_web/src/api/gateway-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type GatewayMemoryBackupRunResponse,
type GatewayMemoryBackupSettingsUpdateRequest,
type GatewayMigrationImportResult,
type GatewayMemoryUpdateStatus,
type GatewayModelCatalog,
type GatewayOnboardingStatus,
type GatewaySkillBinding,
Expand Down Expand Up @@ -626,8 +627,35 @@ export async function restoreMemoryBackup(

return (await response.json()) as GatewayMemoryBackupRestoreResponse;
}

export async function getOwnerProfile(): Promise<string | null> {

export async function getMemoryUpdateStatus(): Promise<GatewayMemoryUpdateStatus> {
const response = await authenticatedFetch(`${GATEWAY_BASE_URL}/updates/memory/status`, {
headers: withLocalOwnerHeaders(),
});

if (!response.ok) {
throw await toGatewayError(response);
}

return (await response.json()) as GatewayMemoryUpdateStatus;
}

export async function getMemoryUpdateReport(migrationId: string): Promise<string> {
const response = await authenticatedFetch(
`${GATEWAY_BASE_URL}/updates/memory/reports/${encodeURIComponent(migrationId)}`,
{
headers: withLocalOwnerHeaders(),
}
);

if (!response.ok) {
throw await toGatewayError(response);
}

return response.text();
}

export async function getOwnerProfile(): Promise<string | null> {
const response = await authenticatedFetch(`${GATEWAY_BASE_URL}/profile`, {
headers: withLocalOwnerHeaders(),
});
Expand Down
11 changes: 11 additions & 0 deletions builds/typescript/client_web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,17 @@ export type GatewayMigrationImportResult = {
logout_required?: boolean;
};

export type GatewayMemoryUpdateStatus = {
current_app_version: string;
memory_pack_version: string;
target_memory_pack_version: string;
pending: boolean;
migration_id: string;
report_path: string | null;
applied_paths: string[];
deferred_paths: string[];
};

export class GatewayError extends Error {
readonly status: number;
readonly code?: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { render } from "@testing-library/react";

import type { Message } from "@/types/ui";

import MessageList from "./MessageList";

const scrollIntoViewMock = vi.fn();

beforeEach(() => {
scrollIntoViewMock.mockReset();
Element.prototype.scrollIntoView = scrollIntoViewMock;
});

describe("MessageList scroll behavior", () => {
it("does not jump to the bottom when an assistant response starts", () => {
const userMessage: Message = { id: "u-1", role: "user", content: "Build me a fitness plan" };
const assistantMessage: Message = { id: "a-1", role: "assistant", content: "Here is a plan..." };

const { rerender } = render(<MessageList messages={[userMessage]} />);
scrollIntoViewMock.mockClear();

rerender(<MessageList messages={[userMessage, assistantMessage]} />);

expect(scrollIntoViewMock).not.toHaveBeenCalled();
});

it("scrolls down when the user submits a new message", () => {
const userMessage: Message = { id: "u-1", role: "user", content: "Build me a fitness plan" };

const { rerender } = render(<MessageList messages={[]} />);
scrollIntoViewMock.mockClear();

rerender(<MessageList messages={[userMessage]} />);

expect(scrollIntoViewMock).toHaveBeenCalledTimes(1);
});
});
17 changes: 14 additions & 3 deletions builds/typescript/client_web/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default function MessageList({
const [showJumpToBottom, setShowJumpToBottom] = useState(false);
const isNearBottomRef = useRef(true);
const prevMessageCountRef = useRef(0);
const hasRenderedMessagesRef = useRef(false);

useEffect(() => {
const container = scrollRef.current;
Expand All @@ -40,14 +41,24 @@ export default function MessageList({
return () => container.removeEventListener("scroll", handleScroll);
}, []);

// Only auto-scroll when a new message is added (not on content updates during streaming)
// Keep assistant replies anchored so users can read from the start as content streams in.
// User messages still scroll down to reveal the submitted prompt and response area.
useEffect(() => {
const messageCount = messages.length;
if (messageCount > prevMessageCountRef.current && isNearBottomRef.current) {
const lastMessage = messages[messageCount - 1];
const shouldScrollForNewUserMessage =
hasRenderedMessagesRef.current &&
messageCount > prevMessageCountRef.current &&
lastMessage?.role === "user" &&
isNearBottomRef.current;

if (shouldScrollForNewUserMessage) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}

hasRenderedMessagesRef.current = true;
prevMessageCountRef.current = messageCount;
}, [messages.length]);
}, [messages]);

function scrollToBottom() {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
Expand Down
80 changes: 78 additions & 2 deletions builds/typescript/client_web/src/components/layout/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Menu } from "lucide-react";
import { CheckCircle2, Menu, X } from "lucide-react";
import { createPortal } from "react-dom";

import { getOnboardingStatus } from "@/api/gateway-adapter";
import { getMemoryUpdateReport, getMemoryUpdateStatus, getOnboardingStatus } from "@/api/gateway-adapter";
import ChatPanel from "@/components/chat/ChatPanel";
import DocumentView from "@/components/document/DocumentView";
import SettingsModal from "@/components/settings/SettingsModal";
Expand Down Expand Up @@ -32,6 +32,12 @@ export default function AppShell({
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [activeFile, setActiveFile] = useState<ProjectFile | null>(null);
const [memoryUpdateNotice, setMemoryUpdateNotice] = useState<{
migrationId: string;
report: string;
hasDeferred: boolean;
} | null>(null);
const [isMemoryUpdateReportOpen, setIsMemoryUpdateReportOpen] = useState(false);
const [mobileHeaderHeight, setMobileHeaderHeight] = useState(0);
const stableAppHeightRef = useRef(0);
const mobileHeaderRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -96,6 +102,33 @@ export default function AppShell({
setActiveFile(null);
}, [selectedProjectId]);

useEffect(() => {
let cancelled = false;
void getMemoryUpdateStatus()
.then(async (status) => {
if (!status.report_path || cancelled) {
return;
}
const storageKey = `braindrive.memoryUpdateReportSeen.${status.migration_id}`;
if (window.localStorage.getItem(storageKey) === "1") {
return;
}
const report = await getMemoryUpdateReport(status.migration_id);
if (cancelled) {
return;
}
setMemoryUpdateNotice({
migrationId: status.migration_id,
report,
hasDeferred: status.deferred_paths.length > 0,
});
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);

useEffect(() => {
if (!mobileHeaderRef.current) {
return;
Expand Down Expand Up @@ -179,6 +212,14 @@ export default function AppShell({
setActiveFile(null);
}

function dismissMemoryUpdateNotice() {
if (memoryUpdateNotice) {
window.localStorage.setItem(`braindrive.memoryUpdateReportSeen.${memoryUpdateNotice.migrationId}`, "1");
}
setMemoryUpdateNotice(null);
setIsMemoryUpdateReportOpen(false);
}

const documentContent = activeFile && selectedProject ? (
<DocumentView
projectId={selectedProject.id}
Expand Down Expand Up @@ -299,6 +340,41 @@ export default function AppShell({
) : null}

<main className="flex min-w-0 flex-1 flex-col overflow-hidden bg-bd-bg-primary" style={appShellVars}>
{memoryUpdateNotice ? (
<div className="border-b border-bd-border bg-bd-bg-secondary px-4 py-3 md:px-5">
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" strokeWidth={1.8} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-bd-text-primary">BrainDrive is up to date.</p>
<p className="mt-0.5 text-xs leading-5 text-bd-text-secondary">
{memoryUpdateNotice.hasDeferred
? "Safe memory updates were applied. One item was left unchanged because it has custom content."
: "Memory instructions were updated so the latest features work correctly."}
</p>
<button
type="button"
onClick={() => setIsMemoryUpdateReportOpen((current) => !current)}
className="mt-2 text-xs font-medium text-bd-amber hover:text-bd-amber-hover"
>
{isMemoryUpdateReportOpen ? "Hide details" : "View details"}
</button>
{isMemoryUpdateReportOpen ? (
<pre className="mt-3 max-h-56 overflow-auto whitespace-pre-wrap rounded-md border border-bd-border bg-bd-bg-primary p-3 text-xs leading-5 text-bd-text-secondary">
{memoryUpdateNotice.report}
</pre>
) : null}
</div>
<button
type="button"
aria-label="Dismiss memory update notice"
onClick={dismissMemoryUpdateNotice}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-bd-text-muted hover:bg-bd-bg-hover hover:text-bd-text-primary"
>
<X size={15} strokeWidth={1.7} />
</button>
</div>
</div>
) : null}
<div
className="flex min-h-0 flex-1 flex-col overflow-hidden pt-[var(--mobile-header-height)] md:pt-0"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2563,8 +2563,9 @@ function AccountSection() {
<button
type="button"
onClick={() => setShowTopupOptions(true)}
className="text-xs text-bd-text-muted hover:text-bd-text-secondary transition-colors"
className="inline-flex items-center gap-1.5 rounded-lg bg-bd-amber px-3 py-1.5 text-xs font-semibold text-bd-bg-primary shadow-sm transition-colors hover:bg-bd-amber-hover"
>
<span aria-hidden="true">+</span>
Need more credits?
</button>
) : (
Expand Down
7 changes: 4 additions & 3 deletions builds/typescript/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,10 @@ function applyAdapterEnvironmentOverrides(config: AdapterConfig): AdapterConfig
}

export async function ensureMemoryLayout(rootDir: string, memoryRoot: string): Promise<void> {
const summary = await initializeMemoryLayout(rootDir, memoryRoot, {
seedDefaultProjects: true,
});
const summary = await initializeMemoryLayout(rootDir, memoryRoot, {
seedDefaultProjects: true,
seedStarterSkills: false,
});
auditLog("memory.init", {
memory_root: memoryRoot,
profile: summary.profile,
Expand Down
Loading
Loading