fix(launch): reliable Windows launcher; feat(ui): compact sidebar, Vite toggle, chat Think toggle

Ported from the private repo via bin/publish.sh, plus a manual catch-up
on files that had drifted out of sync before today:

- launch_nexus.ps1: health-check based restart decisions instead of a
  bare port-listen check (a wedged leftover process squatting a port
  used to look "already running" and block the real service from
  starting), a script-path quoting fix for Start-Process, hidden
  console via a wscript.exe wrapper (bin/launch_nexus_hidden.vbs), and
  a taskbar/window icon for the native app window.
- Sidebar: slim icon+text nav rows instead of bulky bordered buttons,
  tighter spacing throughout.
- Settings: full-width layout, a Vite dev-server Start/Stop toggle
  (synapse/frontend_manager.py + /frontend/* endpoints), and the
  Linux-only Icon Branding section now gated on the new /status
  `platform` field instead of always rendering.
- Chatbot: a Think toggle next to the model picker, so extended
  thinking can be flipped without leaving the chat page.
- management/ncp.py: faster start/stop polling (0.25s steps instead of
  1s), Vite no longer blocks `ncp start` on Linux and is skipped
  outright on Windows.

Note: the private repo also has a Mail (IMAP/SMTP) feature; it's
intentionally not included here, so the Mail-only pieces of main.py,
App.jsx, and requirements-windows.txt were left out of this port.
This commit is contained in:
Jon Wingender
2026-07-28 11:23:35 -05:00
parent 63c93346ae
commit cc20ceac64
11 changed files with 490 additions and 109 deletions
+65 -52
View File
@@ -155,20 +155,20 @@ function App() {
};
const navItems = [
{ key: "chatbot", label: "💬 Chat" },
{ key: "playbook", label: "📖 Playbooks" },
{ key: "models", label: "🤖 Models", badge: isModelPulling },
{ key: "memory", label: "🧠 Memory" },
{ key: "documents", label: "📄 Documents" },
{ key: "logs", label: "📜 Logs" },
{ key: "settings", label: "⚙️ Settings" },
{ key: "chatbot", icon: "💬", label: "Chat" },
{ key: "playbook", icon: "📖", label: "Playbooks" },
{ key: "models", icon: "🤖", label: "Models", badge: isModelPulling },
{ key: "memory", icon: "🧠", label: "Memory" },
{ key: "documents", icon: "📄", label: "Documents" },
{ key: "logs", icon: "📜", label: "Logs" },
{ key: "settings", icon: "⚙️", label: "Settings" },
];
return (
<div style={{ background: "#111", color: "#eee", height: "100vh", overflow: "hidden", fontFamily: "system-ui", display: "flex" }}>
{/* Sidebar */}
<aside style={{
width: "260px",
width: "220px",
flexShrink: 0,
background: "#0a0a0a",
borderRight: "1px solid #333",
@@ -178,18 +178,18 @@ function App() {
}}>
{/* Top cell: brand + nav */}
<div style={{
padding: "1.25rem 1rem 1rem",
padding: "0.75rem 0.6rem 0.65rem",
borderBottom: "1px solid #1f1f1f",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.75rem",
gap: "0.4rem",
flexShrink: 0,
}}>
<img src="/n small.png" alt="Logo" style={{ width: "48px", height: "48px", objectFit: "contain", borderRadius: "8px" }} />
<h1 style={{ fontSize: "1rem", margin: 0, color: "#007acc", textAlign: "center" }}>NexusOS</h1>
<img src="/n small.png" alt="Logo" style={{ width: "36px", height: "36px", objectFit: "contain", borderRadius: "6px" }} />
<h1 style={{ fontSize: "0.9rem", margin: 0, color: "#007acc", textAlign: "center" }}>NexusOS</h1>
{version && (
<span style={{ fontSize: "0.7rem", color: "#666", marginTop: "-0.5rem", letterSpacing: "0.02em" }}>v{version}</span>
<span style={{ fontSize: "0.65rem", color: "#666", marginTop: "-0.35rem", letterSpacing: "0.02em" }}>v{version}</span>
)}
{/* Status Indicator */}
@@ -243,13 +243,13 @@ function App() {
title={ollamaStatus === "unavailable" ? "Ollama binary not found" : "Start or stop the AI"}
style={{
width: "100%",
padding: "0.5rem 0.75rem",
padding: "0.4rem 0.6rem",
background: ollamaStatus === "running" ? "#2a1a1a" : "#152a15",
color: ollamaStatus === "running" ? "#ff8a80" : "#8aff8a",
border: "1px solid " + (ollamaStatus === "running" ? "#5a2a2a" : "#2a5a2a"),
borderRadius: "8px",
borderRadius: "6px",
cursor: (ollamaBusy || ollamaStatus === "unavailable" || status === "Offline") ? "not-allowed" : "pointer",
fontSize: "0.8rem",
fontSize: "0.78rem",
opacity: (ollamaStatus === "unavailable" || status === "Offline") ? 0.5 : 1,
}}
>
@@ -260,39 +260,46 @@ function App() {
: ollamaStatus === "running" ? "⏹ Stop AI" : "▶ Start AI"}
</button>
<nav style={{ display: "flex", flexDirection: "column", gap: "0.4rem", width: "100%", marginTop: "0.25rem" }}>
{navItems.map(item => (
<button
key={item.key}
onClick={() => setCurrentPage(item.key)}
style={{
padding: "0.65rem 0.85rem",
background: currentPage === item.key ? "#007acc" : "#161616",
color: "#fff",
border: "1px solid " + (currentPage === item.key ? "#0099ff" : "#2a2a2a"),
borderRadius: "8px",
cursor: "pointer",
fontSize: "0.85rem",
textAlign: "left",
transition: "all 0.15s",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
{item.label}
{item.badge && (
<span style={{
width: "8px",
height: "8px",
borderRadius: "50%",
background: "#28a745",
flexShrink: 0,
animation: "pulse 1.2s ease-in-out infinite",
}} />
)}
</button>
))}
<nav style={{ display: "flex", flexDirection: "column", width: "100%", marginTop: "0.1rem" }}>
{navItems.map(item => {
const isActive = currentPage === item.key;
return (
<button
key={item.key}
onClick={() => setCurrentPage(item.key)}
style={{
padding: "0.32rem 0.5rem",
background: isActive ? "#1c2733" : "transparent",
color: isActive ? "#fff" : "#bbb",
border: "none",
borderRadius: "5px",
cursor: "pointer",
fontSize: "0.8rem",
textAlign: "left",
transition: "background 0.12s, color 0.12s",
display: "flex",
alignItems: "center",
gap: "0.45rem",
width: "100%",
}}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = "#161616"; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "transparent"; }}
>
<span style={{ fontSize: "0.85rem", width: "1rem", textAlign: "center", flexShrink: 0 }}>{item.icon}</span>
<span style={{ flexGrow: 1 }}>{item.label}</span>
{item.badge && (
<span style={{
width: "6px",
height: "6px",
borderRadius: "50%",
background: "#28a745",
flexShrink: 0,
animation: "pulse 1.2s ease-in-out infinite",
}} />
)}
</button>
);
})}
</nav>
</div>
@@ -302,7 +309,7 @@ function App() {
minHeight: 0,
display: "flex",
flexDirection: "column",
padding: "0.75rem 0.75rem 0.75rem",
padding: "0.6rem 0.6rem 0.6rem",
}}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.5rem", flexShrink: 0 }}>
<span style={{ fontSize: "0.75rem", color: "#888", textTransform: "uppercase", letterSpacing: "0.05em" }}>Chats</span>
@@ -470,13 +477,19 @@ function App() {
}}>
<Models onPullStateChange={setIsModelPulling} />
</div>
{currentPage === "chatbot" && (
{/* Chatbot stays mounted so an in-flight reply survives page navigation */}
<div style={{
display: currentPage === "chatbot" ? "flex" : "none",
flexDirection: "column",
flexGrow: 1,
minHeight: 0,
}}>
<Chatbot
conversationId={activeConversationId}
setConversationId={setActiveConversationId}
onConversationChanged={() => loadConversations(search)}
/>
)}
</div>
{currentPage === "playbook" && <Playbook />}
{currentPage === "memory" && <Memory />}
{currentPage === "documents" && <Documents />}
+40 -1
View File
@@ -10,6 +10,7 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
const [modelList, setModelList] = useState([]);
const [selectedModel, setSelectedModel] = useState(""); // "" = auto
const [autoModel, setAutoModel] = useState(null);
const [think, setThink] = useState(false); // extended thinking, mirrors Settings
const [showPicker, setShowPicker] = useState(false);
const [copiedIdx, setCopiedIdx] = useState(null);
const [lastStats, setLastStats] = useState(null);
@@ -138,7 +139,10 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
]).then(([models, settings]) => {
if (models?.models) setModelList(models.models);
if (models?.selected) setAutoModel(models.selected);
if (settings) setSelectedModel(settings.model || "");
if (settings) {
setSelectedModel(settings.model || "");
setThink(!!settings.think);
}
}).catch(() => {});
}, []);
@@ -163,6 +167,18 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
} catch { /* persisting the model choice is best-effort */ }
};
const toggleThink = async () => {
const next = !think;
setThink(next);
try {
await fetch(`${API_BASE}/settings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ think: next }),
});
} catch { /* persisting the toggle is best-effort */ }
};
const startNewChat = () => {
if (abortRef.current) abortRef.current.abort();
setInput("");
@@ -202,6 +218,7 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
message,
conversation_id: conversationId,
history,
think,
...(imgs && imgs.length ? { images: imgs } : {}),
}),
signal: controller.signal,
@@ -522,6 +539,28 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
</div>
)}
</div>
<button
onClick={toggleThink}
title={think ? "Extended thinking is on — click to turn off" : "Extended thinking is off — click to turn on"}
style={{
fontSize: "0.7rem",
color: think ? "#c4b5fd" : "#888",
background: think ? "#1e1a2e" : "#1a1a1a",
border: "1px solid " + (think ? "#7c3aed" : "#2a2a2a"),
borderRadius: "4px",
padding: "0.15rem 0.5rem",
cursor: "pointer",
letterSpacing: "0.03em",
display: "flex",
alignItems: "center",
gap: "0.3rem",
}}
>
🧠 Think
<span style={{ fontSize: "0.6rem", color: think ? "#8aff8a" : "#555" }}>
{think ? "on" : "off"}
</span>
</button>
{lastStats && (
<span style={{
fontSize: "0.7rem",
+73 -2
View File
@@ -33,6 +33,34 @@ export function Settings() {
const [brandQueue, setBrandQueue] = useState([]);
const [isBranding, setIsBranding] = useState(false);
const [modelList, setModelList] = useState([]);
const [platform, setPlatform] = useState("");
// Vite dev server toggle
const [viteRunning, setViteRunning] = useState(null);
const [viteBusy, setViteBusy] = useState(false);
const [viteError, setViteError] = useState("");
const loadViteStatus = () => {
fetch(`${API_BASE}/frontend/status`)
.then(r => r.ok ? r.json() : null)
.then(d => { if (d) setViteRunning(!!d.running); })
.catch(() => {});
};
const toggleVite = async () => {
setViteBusy(true);
setViteError("");
try {
const action = viteRunning ? "stop" : "start";
const r = await fetch(`${API_BASE}/frontend/${action}`, { method: "POST" });
const d = await r.json().catch(() => null);
if (d && d.status === "error") setViteError(d.detail || "Failed to start Vite");
} catch (e) {
setViteError(e.message || "Failed");
}
setViteBusy(false);
loadViteStatus();
};
useEffect(() => {
fetch(`${API_BASE}/models`).then(r => r.ok ? r.json() : null)
@@ -46,6 +74,13 @@ export function Settings() {
.then(r => r.ok ? r.json() : null)
.then(d => { if (d) setIconApps(d.apps || []); })
.catch(() => {});
fetch(`${API_BASE}/status`)
.then(r => r.ok ? r.json() : null)
.then(d => { if (d) setPlatform(d.platform || ""); })
.catch(() => {});
loadViteStatus();
}, []);
const update = (key, value) => setForm(f => ({ ...f, [key]: value }));
@@ -167,7 +202,7 @@ export function Settings() {
const displayQueue = isBranding ? brandQueue : selectedApps;
return (
<div style={{ maxWidth: "720px" }}>
<div style={{ width: "100%" }}>
<h2 style={{ margin: "0 0 1.25rem", fontSize: "1.1rem", color: "#eee" }}>Settings</h2>
{/* Auto model routing */}
@@ -480,7 +515,42 @@ export function Settings() {
</div>
</div>
{/* Icon Branding */}
{/* Vite Dev Server */}
<div style={sectionStyle}>
<h3 style={{ margin: "0 0 0.5rem", fontSize: "0.95rem", color: "#bbb" }}>Vite Dev Server</h3>
<p style={{ margin: "0 0 1rem", fontSize: "0.75rem", color: "#555" }}>
Hot-reload server for editing the frontend directly (interface/web). Not
needed for normal use — this backend already serves the built UI.
</p>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", flexWrap: "wrap" }}>
<button
onClick={toggleVite}
disabled={viteBusy || viteRunning === null}
style={{
padding: "0.6rem 1.25rem",
background: viteRunning ? "#2a1a1a" : "#152a15",
color: viteRunning ? "#ff8a80" : "#8aff8a",
border: "1px solid " + (viteRunning ? "#5a2a2a" : "#2a5a2a"),
borderRadius: "8px",
cursor: (viteBusy || viteRunning === null) ? "not-allowed" : "pointer",
fontSize: "0.88rem",
fontWeight: 500,
opacity: (viteBusy || viteRunning === null) ? 0.6 : 1,
}}
>
{viteBusy ? (viteRunning ? "Stopping" : "Starting")
: viteRunning ? " Stop Vite" : " Start Vite"}
</button>
<span style={{ fontSize: "0.82rem", color: viteRunning ? "#8aff8a" : "#666" }}>
{viteRunning === null ? "Checking" : viteRunning ? "Running at :5173" : "Stopped"}
</span>
{viteError && <span style={{ color: "#f44336", fontSize: "0.85rem" }}>{viteError}</span>}
</div>
</div>
{/* Icon Branding — Linux-only: composites icons onto XFCE/GTK app tiles,
which don't exist on Windows/macOS. */}
{platform === "linux" && (
<div style={sectionStyle}>
<h3 style={{ margin: "0 0 0.75rem", fontSize: "0.95rem", color: "#bbb" }}>App Icon Branding</h3>
<p style={{ margin: "0 0 1rem", fontSize: "0.78rem", color: "#555" }}>
@@ -638,6 +708,7 @@ export function Settings() {
)}
</div>
</div>
)}
{/* Actions */}
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>